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,1193 @@
|
|
|
1
|
+
"""Augmentation methods (unified registry).
|
|
2
|
+
|
|
3
|
+
Every augmentation method receives a clean canvas (no prior augmentation applied)
|
|
4
|
+
and applies exactly one effect based on its sampled intensity.
|
|
5
|
+
|
|
6
|
+
All methods follow the signature:
|
|
7
|
+
|
|
8
|
+
apply_<name>(img: np.ndarray, intensity: float, **kwargs) -> np.ndarray
|
|
9
|
+
|
|
10
|
+
where *img* is a HxW or HxWxC uint8 numpy array and *intensity* is in [0, 1].
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import os
|
|
16
|
+
import random
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import cv2
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from .logging import get_logger
|
|
24
|
+
|
|
25
|
+
_LOGGER = get_logger("augmentation")
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
os.environ.setdefault("NO_ALBUMENTATIONS_UPDATE", "1")
|
|
29
|
+
import albumentations as A
|
|
30
|
+
|
|
31
|
+
HAS_ALBUMENTATIONS = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
HAS_ALBUMENTATIONS = False
|
|
34
|
+
|
|
35
|
+
# Constants
|
|
36
|
+
|
|
37
|
+
_AUTO_PARALLEL_MIN_SAMPLES = 2_048
|
|
38
|
+
_DEFAULT_RENDER_CHUNK_SIZE = 64
|
|
39
|
+
_MAX_RENDER_CHUNK_SIZE = 256
|
|
40
|
+
_MAX_ERROR_MESSAGES = 10
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Helper
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_odd_kernel_limit(value: int, minimum: int = 3) -> int:
|
|
47
|
+
limit = max(int(minimum), int(value))
|
|
48
|
+
if limit % 2 == 0:
|
|
49
|
+
limit += 1
|
|
50
|
+
return limit
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def match_font_scale(font: str | Path | None, font_scales: dict[str, float] | None) -> float:
|
|
54
|
+
"""Resolve an exact font path first, then a unique filename (validated at renderer setup)."""
|
|
55
|
+
if not font or not font_scales:
|
|
56
|
+
return 1.0
|
|
57
|
+
font_path = Path(font)
|
|
58
|
+
if str(font) in font_scales:
|
|
59
|
+
return font_scales[str(font)]
|
|
60
|
+
for key, factor in font_scales.items():
|
|
61
|
+
if Path(key).name != key and Path(key).resolve() == font_path.resolve():
|
|
62
|
+
return factor
|
|
63
|
+
return font_scales.get(font_path.name, 1.0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def scale_intensity_for_script(
|
|
67
|
+
intensity: float,
|
|
68
|
+
script: str | None,
|
|
69
|
+
script_scales: dict[str, float] | None,
|
|
70
|
+
method_name: str | None = None,
|
|
71
|
+
is_pixel_height: bool = False,
|
|
72
|
+
font: str | Path | None = None,
|
|
73
|
+
font_scales: dict[str, float] | None = None,
|
|
74
|
+
) -> float:
|
|
75
|
+
"""Scale sampled augmentation intensity by script and font multipliers.
|
|
76
|
+
|
|
77
|
+
Script and font factors combine multiplicatively (total_scale = script_scale * font_scale).
|
|
78
|
+
When multiple fonts appear in a mixed or cluster run, the primary font factor determines
|
|
79
|
+
the line-level font multiplier.
|
|
80
|
+
For normalized methods (0-1 intensity), clamps to [0.0, 1.0].
|
|
81
|
+
For pixel-height methods (e.g. extreme_resize), scales absolute pixels
|
|
82
|
+
without clamping to 1.0 (clamping only to minimum 1.0 px).
|
|
83
|
+
"""
|
|
84
|
+
script_scale = 1.0
|
|
85
|
+
if script and script_scales:
|
|
86
|
+
s = script_scales.get(script)
|
|
87
|
+
if s is None:
|
|
88
|
+
for k, v in script_scales.items():
|
|
89
|
+
if k.lower() == script.lower():
|
|
90
|
+
s = v
|
|
91
|
+
break
|
|
92
|
+
if s is not None:
|
|
93
|
+
script_scale = float(s)
|
|
94
|
+
|
|
95
|
+
font_scale = match_font_scale(font, font_scales)
|
|
96
|
+
total_scale = script_scale * font_scale
|
|
97
|
+
|
|
98
|
+
if total_scale == 1.0:
|
|
99
|
+
return intensity
|
|
100
|
+
|
|
101
|
+
if is_pixel_height or method_name == "extreme_resize":
|
|
102
|
+
return float(max(1.0, intensity * total_scale))
|
|
103
|
+
return float(max(0.0, min(1.0, intensity * total_scale)))
|
|
104
|
+
|
|
105
|
+
# Output helpers
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def write_output_image(
|
|
109
|
+
path: Path,
|
|
110
|
+
img: np.ndarray,
|
|
111
|
+
fmt: str = "jpg",
|
|
112
|
+
jpeg_quality: int = 90,
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""Write a numpy image to disk with format-aware encoding.
|
|
115
|
+
|
|
116
|
+
Handles RGB->BGR conversion for OpenCV and sets appropriate params for JPEG quality, PNG compression, and TIFF output.
|
|
117
|
+
"""
|
|
118
|
+
output = img
|
|
119
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
120
|
+
output = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
|
|
121
|
+
|
|
122
|
+
params: list[int] = []
|
|
123
|
+
if fmt in ("jpg", "jpeg"):
|
|
124
|
+
params = [cv2.IMWRITE_JPEG_QUALITY, max(0, min(100, jpeg_quality))]
|
|
125
|
+
elif fmt == "png":
|
|
126
|
+
params = [cv2.IMWRITE_PNG_COMPRESSION, 3]
|
|
127
|
+
elif fmt in ("tiff", "tif"):
|
|
128
|
+
params = [cv2.IMWRITE_TIFF_XDPI, 300, cv2.IMWRITE_TIFF_YDPI, 300]
|
|
129
|
+
|
|
130
|
+
ext = f".{fmt}" if not fmt.startswith(".") else fmt
|
|
131
|
+
out_path = path if path.suffix else path.with_suffix(ext)
|
|
132
|
+
return cv2.imwrite(str(out_path), output, params)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Methods that remove or resample glyph detail. The renderer filters these before
|
|
136
|
+
# method/intensity sampling when the opt-in minimum text-height guard is enabled.
|
|
137
|
+
# This policy also precedes optional Rust dispatch; the low-level functions remain
|
|
138
|
+
# usable independently without a text-height contract.
|
|
139
|
+
TEXT_HEIGHT_GUARDED_METHODS = frozenset(
|
|
140
|
+
{
|
|
141
|
+
"geo_warp",
|
|
142
|
+
"vertical_crop",
|
|
143
|
+
"blur",
|
|
144
|
+
"distortion",
|
|
145
|
+
"rotation",
|
|
146
|
+
"lowdpi",
|
|
147
|
+
"extreme_resize",
|
|
148
|
+
"low_contrast_caption",
|
|
149
|
+
"perspective",
|
|
150
|
+
"elastic",
|
|
151
|
+
"random_crop",
|
|
152
|
+
"online_blur",
|
|
153
|
+
"pixelation",
|
|
154
|
+
"morphological",
|
|
155
|
+
"anisotropic_dilation",
|
|
156
|
+
"luminous_halo",
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Augmentation method implementations
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _estimate_bg(img: np.ndarray) -> int:
|
|
165
|
+
"""Estimate background color from image border."""
|
|
166
|
+
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) if img.ndim == 3 else img
|
|
167
|
+
border = np.concatenate((gray[0, :], gray[-1, :], gray[:, 0], gray[:, -1]))
|
|
168
|
+
return int(np.median(border))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _estimate_bg_per_channel(img: np.ndarray) -> tuple[float, ...]:
|
|
172
|
+
"""Estimate background color from image border, preserving channel tint.
|
|
173
|
+
|
|
174
|
+
Unlike ``_estimate_bg`` (which collapses to a single grayscale value),
|
|
175
|
+
this keeps color information so fill regions blend into tinted
|
|
176
|
+
backgrounds (e.g. paper-tone or colored palettes) instead of showing up
|
|
177
|
+
as a flat gray patch.
|
|
178
|
+
"""
|
|
179
|
+
if img.ndim == 2:
|
|
180
|
+
return (float(_estimate_bg(img)),)
|
|
181
|
+
border = np.concatenate((img[0, :, :], img[-1, :, :], img[:, 0, :], img[:, -1, :]))
|
|
182
|
+
return tuple(float(v) for v in np.median(border, axis=0))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Deviation below this is paper noise, not a glyph. A lower cut would invent ink.
|
|
186
|
+
_HALO_INK_DEV = 28.0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Sauvola
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def apply_sauvola(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
193
|
+
"""Sauvola local-threshold degradation.
|
|
194
|
+
|
|
195
|
+
intensity -> k value in [0.05, 0.50]
|
|
196
|
+
"""
|
|
197
|
+
h, w = img.shape[:2]
|
|
198
|
+
if h < 3 or w < 3:
|
|
199
|
+
return img
|
|
200
|
+
|
|
201
|
+
window = 25
|
|
202
|
+
max_window = min(h, w)
|
|
203
|
+
if max_window % 2 == 0:
|
|
204
|
+
max_window -= 1
|
|
205
|
+
window = min(window, max_window)
|
|
206
|
+
if window % 2 == 0:
|
|
207
|
+
window -= 1
|
|
208
|
+
window = max(3, window)
|
|
209
|
+
|
|
210
|
+
k = 0.05 + intensity * 0.45 # [0.05, 0.50]
|
|
211
|
+
|
|
212
|
+
img_f = img.astype(np.float32)
|
|
213
|
+
mean = cv2.boxFilter(img_f, ddepth=-1, ksize=(window, window))
|
|
214
|
+
sq_mean = cv2.boxFilter(img_f * img_f, ddepth=-1, ksize=(window, window))
|
|
215
|
+
var = np.maximum(sq_mean - mean * mean, 0.0)
|
|
216
|
+
std = np.sqrt(var)
|
|
217
|
+
|
|
218
|
+
r = 128.0
|
|
219
|
+
threshold = mean * (1.0 + k * ((std / r) - 1.0))
|
|
220
|
+
binary = np.where(img_f > threshold, 255.0, 0.0).astype(np.uint8)
|
|
221
|
+
|
|
222
|
+
alpha = 0.45 + intensity * 0.35
|
|
223
|
+
degraded = cv2.addWeighted(img, 1.0 - alpha, binary, alpha, 0.0)
|
|
224
|
+
return degraded
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# Geometric warp
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def apply_geo_warp(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
231
|
+
"""4-point perspective warp. intensity -> radius [0.5, 12] pixels."""
|
|
232
|
+
h, w = img.shape[:2]
|
|
233
|
+
if h < 8 or w < 8:
|
|
234
|
+
return img
|
|
235
|
+
|
|
236
|
+
radius = 0.5 + intensity * 11.5
|
|
237
|
+
bg = _estimate_bg(img)
|
|
238
|
+
|
|
239
|
+
src = np.array(
|
|
240
|
+
[[0.0, 0.0], [float(w - 1), 0.0], [0.0, float(h - 1)], [float(w - 1), float(h - 1)]],
|
|
241
|
+
dtype=np.float32,
|
|
242
|
+
)
|
|
243
|
+
dst = src.copy()
|
|
244
|
+
for i in range(4):
|
|
245
|
+
dx = random.uniform(-radius, radius)
|
|
246
|
+
dy = random.uniform(-radius, radius)
|
|
247
|
+
dst[i] = [
|
|
248
|
+
float(np.clip(dst[i, 0] + dx, 0, w - 1)),
|
|
249
|
+
float(np.clip(dst[i, 1] + dy, 0, h - 1)),
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
mat = cv2.getPerspectiveTransform(src, dst)
|
|
253
|
+
border_value = (float(bg),)
|
|
254
|
+
warped = cv2.warpPerspective(
|
|
255
|
+
img,
|
|
256
|
+
mat,
|
|
257
|
+
(w, h),
|
|
258
|
+
flags=cv2.INTER_LINEAR,
|
|
259
|
+
borderMode=cv2.BORDER_CONSTANT,
|
|
260
|
+
borderValue=border_value,
|
|
261
|
+
)
|
|
262
|
+
return warped
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# Vertical crop
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def apply_vertical_crop(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
269
|
+
"""Crop top/bottom edge and pad back. intensity -> crop [1, 8] px."""
|
|
270
|
+
h, w = img.shape[:2]
|
|
271
|
+
if h < 4:
|
|
272
|
+
return img
|
|
273
|
+
|
|
274
|
+
crop_px = max(1, int(1 + intensity * 7))
|
|
275
|
+
crop_px = min(crop_px, h - 1)
|
|
276
|
+
bg = _estimate_bg(img)
|
|
277
|
+
|
|
278
|
+
fg_threshold = max(0, int(bg) - 20)
|
|
279
|
+
fg_rows = np.where(np.any(img < fg_threshold, axis=1))[0]
|
|
280
|
+
|
|
281
|
+
if random.random() < 0.5:
|
|
282
|
+
shift_px = min(h - 1, crop_px + int(fg_rows[0]) if fg_rows.size > 0 else crop_px)
|
|
283
|
+
pad = np.full((shift_px, w), int(bg), dtype=img.dtype)
|
|
284
|
+
cropped = img[shift_px:, :]
|
|
285
|
+
return np.vstack((cropped, pad))
|
|
286
|
+
else:
|
|
287
|
+
bottom_margin = int((h - 1) - fg_rows[-1]) if fg_rows.size > 0 else 0
|
|
288
|
+
shift_px = min(h - 1, crop_px + bottom_margin)
|
|
289
|
+
pad = np.full((shift_px, w), int(bg), dtype=img.dtype)
|
|
290
|
+
cropped = img[:-shift_px, :]
|
|
291
|
+
return np.vstack((pad, cropped))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _seed_albumentations(target: Any) -> None:
|
|
295
|
+
"""Synchronize an Albumentations transform/pipeline with Python and NumPy random state."""
|
|
296
|
+
if hasattr(target, "set_random_state"):
|
|
297
|
+
s = random.getrandbits(31)
|
|
298
|
+
with contextlib.suppress(Exception):
|
|
299
|
+
target.set_random_state(np.random.default_rng(s), random.Random(s))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# Blur
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def apply_blur(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
306
|
+
"""Blur via Albumentations OneOf(MotionBlur|MedianBlur|GaussianBlur).
|
|
307
|
+
intensity -> kernel size [3, 15].
|
|
308
|
+
"""
|
|
309
|
+
if not HAS_ALBUMENTATIONS:
|
|
310
|
+
# Pure OpenCV fallback
|
|
311
|
+
kernel = _normalize_odd_kernel_limit(int(3 + intensity * 12))
|
|
312
|
+
return cv2.GaussianBlur(img, (kernel, kernel), 0)
|
|
313
|
+
|
|
314
|
+
limit = _normalize_odd_kernel_limit(int(3 + intensity * 12))
|
|
315
|
+
try:
|
|
316
|
+
pipeline = A.OneOf(
|
|
317
|
+
[
|
|
318
|
+
A.MotionBlur(blur_limit=limit, p=1.0),
|
|
319
|
+
A.MedianBlur(blur_limit=limit, p=1.0),
|
|
320
|
+
A.Blur(blur_limit=limit, p=1.0),
|
|
321
|
+
],
|
|
322
|
+
p=1.0,
|
|
323
|
+
)
|
|
324
|
+
_seed_albumentations(pipeline)
|
|
325
|
+
return pipeline(image=img)["image"]
|
|
326
|
+
except Exception:
|
|
327
|
+
_LOGGER.debug("apply_blur: albumentations pipeline failed", exc_info=True)
|
|
328
|
+
return img
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# Distortion
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def apply_distortion(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
335
|
+
"""Distortion via Albumentations OneOf(Optical|Grid|Elastic).
|
|
336
|
+
intensity -> distort_limit [0.05, 0.30].
|
|
337
|
+
"""
|
|
338
|
+
if not HAS_ALBUMENTATIONS:
|
|
339
|
+
return img
|
|
340
|
+
|
|
341
|
+
limit = 0.05 + intensity * 0.25
|
|
342
|
+
try:
|
|
343
|
+
pipeline = A.OneOf(
|
|
344
|
+
[
|
|
345
|
+
A.OpticalDistortion(distort_limit=limit, p=1.0),
|
|
346
|
+
A.GridDistortion(num_steps=5, distort_limit=limit, p=1.0),
|
|
347
|
+
A.ElasticTransform(alpha=float(1 + intensity * 3), sigma=20, p=1.0),
|
|
348
|
+
],
|
|
349
|
+
p=1.0,
|
|
350
|
+
)
|
|
351
|
+
_seed_albumentations(pipeline)
|
|
352
|
+
return pipeline(image=img)["image"]
|
|
353
|
+
except Exception:
|
|
354
|
+
_LOGGER.debug("apply_distortion: albumentations pipeline failed", exc_info=True)
|
|
355
|
+
return img
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# Noise
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def apply_albu_noise(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
362
|
+
"""Noise via Albumentations.
|
|
363
|
+
intensity -> std [0.005, 0.20] in [0,1] units.
|
|
364
|
+
"""
|
|
365
|
+
if not HAS_ALBUMENTATIONS:
|
|
366
|
+
sigma = 3 + intensity * 22
|
|
367
|
+
noise = np.random.normal(0, sigma, img.shape).astype(np.float32)
|
|
368
|
+
return np.clip(img.astype(np.float32) + noise, 0, 255).astype(np.uint8)
|
|
369
|
+
|
|
370
|
+
std_hi = 0.005 + intensity * 0.195
|
|
371
|
+
std_lo = std_hi * 0.15
|
|
372
|
+
try:
|
|
373
|
+
pipeline = A.OneOf(
|
|
374
|
+
[
|
|
375
|
+
A.GaussNoise(std_range=(std_lo, std_hi), p=1.0),
|
|
376
|
+
A.MultiplicativeNoise(
|
|
377
|
+
multiplier=(1.0 - intensity * 0.2, 1.0 + intensity * 0.2),
|
|
378
|
+
elementwise=True,
|
|
379
|
+
p=1.0,
|
|
380
|
+
),
|
|
381
|
+
],
|
|
382
|
+
p=1.0,
|
|
383
|
+
)
|
|
384
|
+
_seed_albumentations(pipeline)
|
|
385
|
+
return pipeline(image=img)["image"]
|
|
386
|
+
except Exception:
|
|
387
|
+
_LOGGER.debug("apply_albu_noise: albumentations pipeline failed", exc_info=True)
|
|
388
|
+
return img
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# JPEG compression
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def apply_jpeg_compression(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
395
|
+
"""JPEG compression artifacts.
|
|
396
|
+
intensity -> quality [95, 25] (inverted).
|
|
397
|
+
"""
|
|
398
|
+
quality = max(5, int(95 - intensity * 70))
|
|
399
|
+
encode_arr = img if img.ndim == 2 else cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
|
|
400
|
+
ok, buf = cv2.imencode(".jpg", encode_arr, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
|
401
|
+
if not ok:
|
|
402
|
+
return img
|
|
403
|
+
flag = cv2.IMREAD_GRAYSCALE if img.ndim == 2 else cv2.IMREAD_COLOR
|
|
404
|
+
decoded = cv2.imdecode(buf, flag)
|
|
405
|
+
if decoded is None:
|
|
406
|
+
return img
|
|
407
|
+
if img.ndim == 3:
|
|
408
|
+
decoded = cv2.cvtColor(decoded, cv2.COLOR_BGR2RGB)
|
|
409
|
+
return decoded
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# Rotation
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def apply_rotation(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
416
|
+
"""Random rotation.
|
|
417
|
+
intensity -> max degrees [0.5, 8.0].
|
|
418
|
+
"""
|
|
419
|
+
angle = random.uniform(-(0.5 + intensity * 7.5), 0.5 + intensity * 7.5)
|
|
420
|
+
if abs(angle) < 0.01:
|
|
421
|
+
return img
|
|
422
|
+
|
|
423
|
+
h, w = img.shape[:2]
|
|
424
|
+
cx, cy = w / 2.0, h / 2.0
|
|
425
|
+
M = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
|
|
426
|
+
|
|
427
|
+
# Expand the canvas to fully contain the rotated content (otherwise corners
|
|
428
|
+
# get clipped), then resize back to the original size so callers get the
|
|
429
|
+
# same output dimensions.
|
|
430
|
+
cos = abs(M[0, 0])
|
|
431
|
+
sin = abs(M[0, 1])
|
|
432
|
+
new_w = round(h * sin + w * cos)
|
|
433
|
+
new_h = round(h * cos + w * sin)
|
|
434
|
+
M[0, 2] += (new_w / 2.0) - cx
|
|
435
|
+
M[1, 2] += (new_h / 2.0) - cy
|
|
436
|
+
|
|
437
|
+
border_val = _estimate_bg_per_channel(img)
|
|
438
|
+
rotated = cv2.warpAffine(
|
|
439
|
+
img,
|
|
440
|
+
M,
|
|
441
|
+
(new_w, new_h),
|
|
442
|
+
flags=cv2.INTER_LINEAR,
|
|
443
|
+
borderMode=cv2.BORDER_CONSTANT,
|
|
444
|
+
borderValue=border_val,
|
|
445
|
+
)
|
|
446
|
+
if (new_w, new_h) != (w, h):
|
|
447
|
+
rotated = cv2.resize(rotated, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
448
|
+
return rotated
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# Salt-and-pepper noise
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def apply_salt_pepper(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
455
|
+
"""Salt-and-pepper noise.
|
|
456
|
+
intensity -> density [0.001, 0.04].
|
|
457
|
+
"""
|
|
458
|
+
density = 0.001 + intensity * 0.039
|
|
459
|
+
out = img.copy()
|
|
460
|
+
total = img.size
|
|
461
|
+
n_noise = max(1, int(total * density))
|
|
462
|
+
|
|
463
|
+
if img.ndim == 2:
|
|
464
|
+
# Salt (white)
|
|
465
|
+
rows = np.random.randint(0, img.shape[0], n_noise // 2)
|
|
466
|
+
cols = np.random.randint(0, img.shape[1], n_noise // 2)
|
|
467
|
+
out[rows, cols] = 255
|
|
468
|
+
# Pepper (black)
|
|
469
|
+
rows = np.random.randint(0, img.shape[0], n_noise // 2)
|
|
470
|
+
cols = np.random.randint(0, img.shape[1], n_noise // 2)
|
|
471
|
+
out[rows, cols] = 0
|
|
472
|
+
else:
|
|
473
|
+
h, w = img.shape[:2]
|
|
474
|
+
rows = np.random.randint(0, h, n_noise // 2)
|
|
475
|
+
cols = np.random.randint(0, w, n_noise // 2)
|
|
476
|
+
out[rows, cols] = 255
|
|
477
|
+
rows = np.random.randint(0, h, n_noise // 2)
|
|
478
|
+
cols = np.random.randint(0, w, n_noise // 2)
|
|
479
|
+
out[rows, cols] = 0
|
|
480
|
+
return out
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# Background texture
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def apply_background_texture(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
487
|
+
"""Procedural paper/document texture overlay.
|
|
488
|
+
|
|
489
|
+
Eight texture modes are sampled uniformly at random:
|
|
490
|
+
|
|
491
|
+
- Mode 0: Fine grain noise (intensity -> alpha [0.05, 0.30]).
|
|
492
|
+
- Mode 1: Coarse Gaussian low-frequency noise.
|
|
493
|
+
- Mode 2: Horizontal paper-fiber streaks (grayscale only).
|
|
494
|
+
- Mode 3: Document crease/fold line with shadow & highlight edge.
|
|
495
|
+
- Mode 4: Watermark / coffee-stain elliptical blotch.
|
|
496
|
+
- Mode 5: Antique parchment (multi-scale octave frequency noise).
|
|
497
|
+
- Mode 6: Lined / grid notebook-paper ruling.
|
|
498
|
+
- Mode 7: Scanner dust & speckle artefacts.
|
|
499
|
+
|
|
500
|
+
In all modes, texture is suppressed over dark ink pixels to avoid obscuring text.
|
|
501
|
+
intensity is in [0, 1].
|
|
502
|
+
"""
|
|
503
|
+
h, w = img.shape[:2]
|
|
504
|
+
bg = _estimate_bg(img)
|
|
505
|
+
out = img.astype(np.float32)
|
|
506
|
+
|
|
507
|
+
mode = random.randint(0, 7)
|
|
508
|
+
|
|
509
|
+
if mode == 0:
|
|
510
|
+
# Fine Gaussian grain
|
|
511
|
+
sigma = 1.0 + intensity * 10.0
|
|
512
|
+
grain = np.random.normal(0.0, sigma, (h, w)).astype(np.float32)
|
|
513
|
+
if out.ndim == 3:
|
|
514
|
+
grain = grain[..., None]
|
|
515
|
+
alpha = 0.05 + intensity * 0.25
|
|
516
|
+
out = out + grain * alpha
|
|
517
|
+
|
|
518
|
+
elif mode == 1:
|
|
519
|
+
# Coarse low-frequency noise
|
|
520
|
+
coarse = np.random.normal(0.0, 20.0, (max(1, h // 4), max(1, w // 4))).astype(np.float32)
|
|
521
|
+
coarse = cv2.resize(coarse, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
522
|
+
coarse = cv2.GaussianBlur(coarse, (0, 0), sigmaX=max(1.0, w * 0.05))
|
|
523
|
+
if out.ndim == 3:
|
|
524
|
+
coarse = coarse[..., None]
|
|
525
|
+
alpha = 0.03 + intensity * 0.20
|
|
526
|
+
out = out + coarse * alpha
|
|
527
|
+
|
|
528
|
+
elif mode == 2:
|
|
529
|
+
# Paper-fiber horizontal streaks (grayscale only)
|
|
530
|
+
if out.ndim == 2:
|
|
531
|
+
streak_count = random.randint(3, 12)
|
|
532
|
+
for _ in range(streak_count):
|
|
533
|
+
y = random.randint(0, h - 1)
|
|
534
|
+
intensity_streak = random.uniform(-12.0, 12.0)
|
|
535
|
+
angle = random.uniform(-0.05, 0.05)
|
|
536
|
+
for dy in range(random.randint(1, 2)):
|
|
537
|
+
row = y + dy
|
|
538
|
+
if row >= h:
|
|
539
|
+
break
|
|
540
|
+
xs = np.arange(w, dtype=np.float32)
|
|
541
|
+
ys = np.clip((row + xs * angle).astype(np.int32), 0, h - 1)
|
|
542
|
+
out[ys, np.arange(w)] += intensity_streak
|
|
543
|
+
|
|
544
|
+
elif mode == 3:
|
|
545
|
+
# Document crease / fold line with shadow + highlight edge
|
|
546
|
+
direction = random.choice(["horizontal", "vertical"])
|
|
547
|
+
alpha_shadow = 0.06 + intensity * 0.20
|
|
548
|
+
alpha_highlight = 0.04 + intensity * 0.12
|
|
549
|
+
half_w = max(1, int(2 + intensity * 4))
|
|
550
|
+
if direction == "horizontal":
|
|
551
|
+
cy = random.randint(h // 6, 5 * h // 6)
|
|
552
|
+
for dy in range(-half_w, half_w + 1):
|
|
553
|
+
row = cy + dy
|
|
554
|
+
if not (0 <= row < h):
|
|
555
|
+
continue
|
|
556
|
+
# shadow side
|
|
557
|
+
blend = alpha_shadow * (1.0 - abs(dy) / (half_w + 1))
|
|
558
|
+
out[row] = out[row] - 255.0 * blend
|
|
559
|
+
highlight_row = min(h - 1, cy + half_w + 1)
|
|
560
|
+
out[highlight_row] = out[highlight_row] + 255.0 * alpha_highlight
|
|
561
|
+
else:
|
|
562
|
+
cx = random.randint(w // 6, 5 * w // 6)
|
|
563
|
+
for dx in range(-half_w, half_w + 1):
|
|
564
|
+
col = cx + dx
|
|
565
|
+
if not (0 <= col < w):
|
|
566
|
+
continue
|
|
567
|
+
blend = alpha_shadow * (1.0 - abs(dx) / (half_w + 1))
|
|
568
|
+
out[:, col] = out[:, col] - 255.0 * blend
|
|
569
|
+
highlight_col = min(w - 1, cx + half_w + 1)
|
|
570
|
+
out[:, highlight_col] = out[:, highlight_col] + 255.0 * alpha_highlight
|
|
571
|
+
|
|
572
|
+
elif mode == 4:
|
|
573
|
+
# Watermark / coffee-stain elliptical blotch
|
|
574
|
+
cx = random.randint(w // 4, 3 * w // 4)
|
|
575
|
+
cy = random.randint(h // 4, 3 * h // 4)
|
|
576
|
+
rx = max(4, int(w * (0.05 + intensity * 0.25)))
|
|
577
|
+
ry = max(4, int(h * (0.10 + intensity * 0.30)))
|
|
578
|
+
# Build a soft elliptical mask
|
|
579
|
+
yy, xx = np.ogrid[:h, :w]
|
|
580
|
+
dist = ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2
|
|
581
|
+
mask = np.clip(1.0 - dist, 0.0, 1.0).astype(np.float32)
|
|
582
|
+
if out.ndim == 3:
|
|
583
|
+
mask = mask[..., None]
|
|
584
|
+
# Randomise tone: warm yellow-brown tint for grayscale, slight darkening
|
|
585
|
+
tint = random.uniform(-18.0, -6.0) * intensity # slightly darken background
|
|
586
|
+
out = out + mask * tint
|
|
587
|
+
|
|
588
|
+
elif mode == 5:
|
|
589
|
+
# Antique parchment: multi-scale octave frequency noise
|
|
590
|
+
parchment = np.zeros((h, w), dtype=np.float32)
|
|
591
|
+
amplitude = 1.0
|
|
592
|
+
scale_h, scale_w = h, w
|
|
593
|
+
for _ in range(4):
|
|
594
|
+
scale_h = max(1, scale_h // 2)
|
|
595
|
+
scale_w = max(1, scale_w // 2)
|
|
596
|
+
octave = np.random.normal(0.0, 1.0, (scale_h, scale_w)).astype(np.float32)
|
|
597
|
+
octave = cv2.resize(octave, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
598
|
+
parchment += octave * amplitude
|
|
599
|
+
amplitude *= 0.5
|
|
600
|
+
# Normalise and scale
|
|
601
|
+
parchment = (parchment - parchment.min()) / (parchment.max() - parchment.min() + 1e-6)
|
|
602
|
+
parchment = (parchment - 0.5) * (10.0 + intensity * 25.0)
|
|
603
|
+
if out.ndim == 3:
|
|
604
|
+
parchment = parchment[..., None]
|
|
605
|
+
out = out + parchment
|
|
606
|
+
|
|
607
|
+
elif mode == 6:
|
|
608
|
+
# Lined / grid notebook paper ruling
|
|
609
|
+
rule_alpha = 0.04 + intensity * 0.18
|
|
610
|
+
line_style = random.choice(["horizontal", "grid"])
|
|
611
|
+
line_spacing = random.randint(max(2, h // 12), max(3, h // 5))
|
|
612
|
+
line_color = float(bg) * (1.0 - rule_alpha) + (180.0 if bg > 128 else 200.0) * rule_alpha
|
|
613
|
+
# Horizontal lines
|
|
614
|
+
for y in range(0, h, line_spacing):
|
|
615
|
+
thickness = 1
|
|
616
|
+
out[y : y + thickness] = (
|
|
617
|
+
out[y : y + thickness] * (1.0 - rule_alpha) + line_color * rule_alpha
|
|
618
|
+
)
|
|
619
|
+
if line_style == "grid":
|
|
620
|
+
col_spacing = random.randint(max(4, w // 20), max(8, w // 8))
|
|
621
|
+
for x in range(0, w, col_spacing):
|
|
622
|
+
thickness = 1
|
|
623
|
+
out[:, x : x + thickness] = (
|
|
624
|
+
out[:, x : x + thickness] * (1.0 - rule_alpha) + line_color * rule_alpha
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
else:
|
|
628
|
+
# mode 7: Scanner dust & speckle artefacts
|
|
629
|
+
speckle_count = int(5 + intensity * 40)
|
|
630
|
+
for _ in range(speckle_count):
|
|
631
|
+
sx = random.randint(0, w - 1)
|
|
632
|
+
sy = random.randint(0, h - 1)
|
|
633
|
+
r = random.randint(1, max(1, int(1 + intensity * 3)))
|
|
634
|
+
# Alternate between dark dust and bright scratch
|
|
635
|
+
val = random.choice([-40.0, -20.0, 30.0, 50.0]) * intensity
|
|
636
|
+
y1, y2 = max(0, sy - r), min(h, sy + r + 1)
|
|
637
|
+
x1, x2 = max(0, sx - r), min(w, sx + r + 1)
|
|
638
|
+
out[y1:y2, x1:x2] = out[y1:y2, x1:x2] + val
|
|
639
|
+
|
|
640
|
+
# Suppress texture over dark ink pixels (all modes, grayscale only)
|
|
641
|
+
if img.ndim == 2:
|
|
642
|
+
bg_f = float(bg)
|
|
643
|
+
ink_strength = np.clip((bg_f - img.astype(np.float32)) / max(1.0, bg_f), 0.0, 1.0)
|
|
644
|
+
out = out * (1.0 - ink_strength) + img.astype(np.float32) * ink_strength
|
|
645
|
+
|
|
646
|
+
return np.clip(out, 0.0, 255.0).astype(np.uint8)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# Low-contrast small caption text
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def apply_low_contrast_caption(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
653
|
+
"""Simulate faded, small-point caption/footnote text.
|
|
654
|
+
|
|
655
|
+
Combines two effects that co-occur when small caption text is scanned or
|
|
656
|
+
photocopied: the dynamic range collapses toward a mid-tone (poor contrast)
|
|
657
|
+
and fine stroke detail softens as if the glyphs were rendered at a small
|
|
658
|
+
point size and then rescaled.
|
|
659
|
+
|
|
660
|
+
intensity -> contrast compression [0.15, 0.65] and downscale ratio [0.85, 0.45].
|
|
661
|
+
"""
|
|
662
|
+
h, w = img.shape[:2]
|
|
663
|
+
if h < 4 or w < 4:
|
|
664
|
+
return img
|
|
665
|
+
|
|
666
|
+
bg = _estimate_bg(img)
|
|
667
|
+
|
|
668
|
+
# Compress dynamic range toward a blend of background tone and neutral gray.
|
|
669
|
+
compression = 0.15 + intensity * 0.50
|
|
670
|
+
mid = float(bg) * 0.6 + 128.0 * 0.4
|
|
671
|
+
faded = img.astype(np.float32)
|
|
672
|
+
faded = faded + (mid - faded) * compression
|
|
673
|
+
faded = np.clip(faded, 0.0, 255.0).astype(np.uint8)
|
|
674
|
+
|
|
675
|
+
# Soften fine detail via a downscale/upscale pass, mimicking a small font
|
|
676
|
+
# rendered at low effective resolution.
|
|
677
|
+
ratio = 0.85 - intensity * 0.40 # [0.45, 0.85]
|
|
678
|
+
small_h = max(2, int(h * ratio))
|
|
679
|
+
small_w = max(2, int(w * ratio))
|
|
680
|
+
small = cv2.resize(faded, (small_w, small_h), interpolation=cv2.INTER_AREA)
|
|
681
|
+
softened = cv2.resize(small, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
682
|
+
|
|
683
|
+
return softened
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# Low DPI
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def apply_lowdpi(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
690
|
+
"""Simulate low-DPI rendering via downscale-then-upscale.
|
|
691
|
+
intensity -> downsample ratio [0.15, 0.80].
|
|
692
|
+
"""
|
|
693
|
+
h, w = img.shape[:2]
|
|
694
|
+
ratio = 0.80 - intensity * 0.65 # [0.15, 0.80]
|
|
695
|
+
small_h = max(2, int(h * ratio))
|
|
696
|
+
small_w = max(2, int(w * ratio))
|
|
697
|
+
small = cv2.resize(img, (small_w, small_h), interpolation=cv2.INTER_NEAREST)
|
|
698
|
+
return cv2.resize(small, (w, h), interpolation=cv2.INTER_LANCZOS4)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# Oversample
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def apply_oversample(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
705
|
+
"""Oversample rendering.
|
|
706
|
+
intensity -> scale factor [1.5, 4.0] then downscale.
|
|
707
|
+
Requires *font* kwarg for rendering hint; this is a rendering-mode augmentation.
|
|
708
|
+
"""
|
|
709
|
+
# Oversample is handled differently — it's a rendering mode, not post-hoc.
|
|
710
|
+
# When called post-hoc, just do a mild sharpen.
|
|
711
|
+
alpha = 1.0 + intensity * 0.5
|
|
712
|
+
kernel = np.array([[-0.5, -0.5, -0.5], [-0.5, 5.0 * alpha, -0.5], [-0.5, -0.5, -0.5]])
|
|
713
|
+
return cv2.filter2D(img, -1, kernel)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# Extreme resize
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def apply_extreme_resize(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
720
|
+
"""Simulate an extreme source resolution before normalisation.
|
|
721
|
+
|
|
722
|
+
Unlike other methods, *intensity* is not a [0, 1] fraction: it's an
|
|
723
|
+
absolute target height in pixels, sampled directly from the method's
|
|
724
|
+
configured (unbounded) min/max height range -- e.g. 8..1920. The clean
|
|
725
|
+
canvas is isotropically resized to that height (width scales with it),
|
|
726
|
+
simulating a very small source (later upscaled back to line height by
|
|
727
|
+
the pipeline, producing blockiness) or a very large one (softened by the
|
|
728
|
+
down/up interpolation). The pipeline's post-augmentation resize-to-target
|
|
729
|
+
step restores the final output size.
|
|
730
|
+
"""
|
|
731
|
+
h, w = img.shape[:2]
|
|
732
|
+
if h <= 0:
|
|
733
|
+
return img
|
|
734
|
+
target_h = max(1, round(intensity))
|
|
735
|
+
scale = target_h / h
|
|
736
|
+
target_w = max(1, round(w * scale))
|
|
737
|
+
interp = cv2.INTER_AREA if target_h < h else cv2.INTER_LINEAR
|
|
738
|
+
return cv2.resize(img, (target_w, target_h), interpolation=interp)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# Perspective warp (online)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def apply_perspective(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
745
|
+
"""Mild perspective warp.
|
|
746
|
+
intensity -> max displacement fraction [0.02, 0.12].
|
|
747
|
+
"""
|
|
748
|
+
h, w = img.shape[:2]
|
|
749
|
+
if h < 10 or w < 10:
|
|
750
|
+
return img
|
|
751
|
+
|
|
752
|
+
d = min(h, w) * (0.02 + intensity * 0.10)
|
|
753
|
+
src = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype=np.float32)
|
|
754
|
+
dst = np.array(
|
|
755
|
+
[
|
|
756
|
+
[random.uniform(0, d), random.uniform(0, d)],
|
|
757
|
+
[w - random.uniform(0, d), random.uniform(0, d)],
|
|
758
|
+
[w - random.uniform(0, d), h - random.uniform(0, d)],
|
|
759
|
+
[random.uniform(0, d), h - random.uniform(0, d)],
|
|
760
|
+
],
|
|
761
|
+
dtype=np.float32,
|
|
762
|
+
)
|
|
763
|
+
m = cv2.getPerspectiveTransform(src, dst)
|
|
764
|
+
border_val: tuple[int, int, int] | tuple[int] = (128, 128, 128) if img.ndim == 3 else (128,)
|
|
765
|
+
return cv2.warpPerspective(img, m, (w, h), borderValue=border_val)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# Elastic distortion
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def apply_elastic(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
772
|
+
"""Elastic distortion via displacement field.
|
|
773
|
+
intensity -> sigma [0.02*size, 0.12*size].
|
|
774
|
+
"""
|
|
775
|
+
h, w = img.shape[:2]
|
|
776
|
+
if h < 10 or w < 10:
|
|
777
|
+
return img
|
|
778
|
+
|
|
779
|
+
sigma = min(h, w) * (0.02 + intensity * 0.10)
|
|
780
|
+
scale = 8
|
|
781
|
+
ch, cw = max(2, h // scale), max(2, w // scale)
|
|
782
|
+
dx = (np.random.rand(ch, cw).astype(np.float32) * 2 - 1) * sigma
|
|
783
|
+
dy = (np.random.rand(ch, cw).astype(np.float32) * 2 - 1) * sigma
|
|
784
|
+
dx = cv2.resize(dx, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
785
|
+
dy = cv2.resize(dy, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
786
|
+
x_coords, y_coords = np.meshgrid(np.arange(w), np.arange(h))
|
|
787
|
+
map_x = np.clip(x_coords + dx, 0, w - 1).astype(np.float32)
|
|
788
|
+
map_y = np.clip(y_coords + dy, 0, h - 1).astype(np.float32)
|
|
789
|
+
return cv2.remap(img, map_x, map_y, cv2.INTER_LINEAR)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# Random height crop
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def apply_random_crop(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
796
|
+
"""Crop up to a fraction of height from top/bottom, resize back.
|
|
797
|
+
intensity -> crop fraction [0.01, 0.08].
|
|
798
|
+
"""
|
|
799
|
+
h, w = img.shape[:2]
|
|
800
|
+
if h < 8:
|
|
801
|
+
return img
|
|
802
|
+
|
|
803
|
+
max_cut = max(1, int(h * (0.01 + intensity * 0.07)))
|
|
804
|
+
cut = random.randint(1, max_cut)
|
|
805
|
+
cropped = img[cut:, :] if random.random() < 0.5 else img[: h - cut, :]
|
|
806
|
+
return cv2.resize(cropped, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# Online blur
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def apply_online_blur(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
813
|
+
"""Gaussian or Motion blur.
|
|
814
|
+
intensity -> kernel size [3, 7].
|
|
815
|
+
"""
|
|
816
|
+
kernel = _normalize_odd_kernel_limit(int(3 + intensity * 4))
|
|
817
|
+
|
|
818
|
+
if HAS_ALBUMENTATIONS and random.random() < 0.5:
|
|
819
|
+
try:
|
|
820
|
+
op = A.MotionBlur(blur_limit=kernel, p=1.0)
|
|
821
|
+
_seed_albumentations(op)
|
|
822
|
+
return op(image=img)["image"]
|
|
823
|
+
except Exception:
|
|
824
|
+
_LOGGER.debug("apply_online_blur: MotionBlur failed", exc_info=True)
|
|
825
|
+
|
|
826
|
+
return cv2.GaussianBlur(img, (kernel, kernel), 0)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# Online noise
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def apply_online_noise(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
833
|
+
"""Additive Gaussian noise.
|
|
834
|
+
intensity -> sigma [3, 30].
|
|
835
|
+
"""
|
|
836
|
+
sigma = 3.0 + intensity * 27.0
|
|
837
|
+
noise = np.random.normal(0, sigma, img.shape).astype(np.float32)
|
|
838
|
+
return np.clip(img.astype(np.float32) + noise, 0, 255).astype(np.uint8)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
# HSV color jitter
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def apply_hsv(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
845
|
+
"""HSV saturation/value jitter.
|
|
846
|
+
intensity -> shift factor [0.05, 0.40].
|
|
847
|
+
Grayscale images pass through unchanged.
|
|
848
|
+
"""
|
|
849
|
+
if img.ndim != 3:
|
|
850
|
+
return img
|
|
851
|
+
|
|
852
|
+
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV).astype(np.float32)
|
|
853
|
+
factor = 1.0 - intensity * 0.40 # [0.60, 0.95]
|
|
854
|
+
hsv[:, :, 1] = np.clip(hsv[:, :, 1] * random.uniform(factor, 2.0 - factor), 0, 255)
|
|
855
|
+
hsv[:, :, 2] = np.clip(hsv[:, :, 2] * random.uniform(factor, 2.0 - factor), 0, 255)
|
|
856
|
+
return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
# Color reversal
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def apply_reverse(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
863
|
+
"""Color reversal (dark <-> light).
|
|
864
|
+
intensity is ignored (binary on/off effect).
|
|
865
|
+
"""
|
|
866
|
+
return (255 - img).astype(np.uint8)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# Brightness/contrast
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def apply_brightness_contrast(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
873
|
+
"""Brightness/contrast jitter.
|
|
874
|
+
intensity -> adjustment range [0.02, 0.25].
|
|
875
|
+
"""
|
|
876
|
+
spread = 0.02 + intensity * 0.23
|
|
877
|
+
alpha = random.uniform(1.0 - spread, 1.0 + spread) # Contrast
|
|
878
|
+
beta = int(random.uniform(-30 * intensity, 30 * intensity)) # Brightness
|
|
879
|
+
return cv2.convertScaleAbs(img, alpha=alpha, beta=beta)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# Pixelation
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def apply_pixelation(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
886
|
+
"""Downscale-then-upscale pixelation.
|
|
887
|
+
intensity -> scale fraction [0.2, 0.9].
|
|
888
|
+
"""
|
|
889
|
+
h, w = img.shape[:2]
|
|
890
|
+
scale = 0.9 - intensity * 0.7 # [0.2, 0.9]
|
|
891
|
+
small_w = max(1, int(w * scale))
|
|
892
|
+
small_h = max(1, int(h * scale))
|
|
893
|
+
interp = random.choice([cv2.INTER_NEAREST, cv2.INTER_LINEAR])
|
|
894
|
+
small = cv2.resize(img, (small_w, small_h), interpolation=interp)
|
|
895
|
+
return cv2.resize(small, (w, h), interpolation=interp)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
# Gradient illumination
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def apply_gradient_illumination(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
902
|
+
"""Gradient illumination overlay.
|
|
903
|
+
intensity -> gradient strength [0.1, 0.6].
|
|
904
|
+
"""
|
|
905
|
+
h, w = img.shape[:2]
|
|
906
|
+
strength = 0.1 + intensity * 0.5
|
|
907
|
+
|
|
908
|
+
if random.random() < 0.5:
|
|
909
|
+
gradient = np.linspace(1.0 - strength, 1.0, w)
|
|
910
|
+
if random.random() < 0.5:
|
|
911
|
+
gradient = gradient[::-1]
|
|
912
|
+
gradient = gradient[np.newaxis, :]
|
|
913
|
+
else:
|
|
914
|
+
gradient = np.linspace(1.0 - strength, 1.0, h)
|
|
915
|
+
if random.random() < 0.5:
|
|
916
|
+
gradient = gradient[::-1]
|
|
917
|
+
gradient = gradient[:, np.newaxis]
|
|
918
|
+
|
|
919
|
+
if img.ndim == 3:
|
|
920
|
+
# Add a trailing channel axis so the gradient broadcasts against
|
|
921
|
+
# (h, w, c) instead of colliding with the channel dimension.
|
|
922
|
+
gradient = gradient[..., np.newaxis]
|
|
923
|
+
|
|
924
|
+
return np.clip(img.astype(np.float32) * gradient, 0, 255).astype(np.uint8)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# Morphological
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def apply_morphological(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
931
|
+
"""Morphological erode/dilate.
|
|
932
|
+
intensity -> kernel size [2, 3].
|
|
933
|
+
"""
|
|
934
|
+
has_khmer = kwargs.get("has_khmer", False)
|
|
935
|
+
kernel_size = 2 if intensity < 0.5 else 3
|
|
936
|
+
|
|
937
|
+
if has_khmer:
|
|
938
|
+
if random.random() < 0.7:
|
|
939
|
+
kernel = np.ones((kernel_size, kernel_size), np.uint8)
|
|
940
|
+
return cv2.erode(img, kernel, iterations=1)
|
|
941
|
+
else:
|
|
942
|
+
kernel = np.ones((1, kernel_size), np.uint8)
|
|
943
|
+
return cv2.dilate(img, kernel, iterations=1)
|
|
944
|
+
else:
|
|
945
|
+
kernel = np.ones((kernel_size, kernel_size), np.uint8)
|
|
946
|
+
if random.random() < 0.5:
|
|
947
|
+
return cv2.erode(img, kernel, iterations=1)
|
|
948
|
+
else:
|
|
949
|
+
return cv2.dilate(img, kernel, iterations=1)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
# Anisotropic dilation
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def apply_anisotropic_dilation(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
956
|
+
"""Anisotropic dilation (dot-matrix spread).
|
|
957
|
+
intensity -> kernel size [2, 4].
|
|
958
|
+
"""
|
|
959
|
+
kernel_size = 2 if intensity < 0.5 else random.choice([3, 4])
|
|
960
|
+
if random.random() < 0.5:
|
|
961
|
+
kernel = np.ones((1, kernel_size), np.uint8)
|
|
962
|
+
else:
|
|
963
|
+
kernel = np.ones((kernel_size, 1), np.uint8)
|
|
964
|
+
return cv2.dilate(img, kernel, iterations=1)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
# Luminous halo
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def apply_luminous_halo(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray:
|
|
971
|
+
"""Soft outer bloom. RGB also gets a near-white glyph fill.
|
|
972
|
+
|
|
973
|
+
RGB input (color-mode 3) gets a near-white core and a mostly-green bloom,
|
|
974
|
+
sometimes cyan or magenta. Grayscale keeps the dark core; a light fringe
|
|
975
|
+
sits outside it and does not lift the ink. Colored bloom needs color-mode 3.
|
|
976
|
+
Not in ``_RGB_PREFERRED_METHODS``: that path converts the result back to
|
|
977
|
+
gray and would drop the hue.
|
|
978
|
+
|
|
979
|
+
intensity -> blur sigma and halo opacity. Sigma is also capped so the
|
|
980
|
+
bloom fades before the canvas edge instead of clipping into a hard rim.
|
|
981
|
+
"""
|
|
982
|
+
if img.ndim == 2:
|
|
983
|
+
channels = 1
|
|
984
|
+
elif img.ndim == 3 and img.shape[2] == 3:
|
|
985
|
+
channels = 3
|
|
986
|
+
else:
|
|
987
|
+
return img
|
|
988
|
+
|
|
989
|
+
h, w = img.shape[:2]
|
|
990
|
+
if h < 3 or w < 3:
|
|
991
|
+
return img
|
|
992
|
+
|
|
993
|
+
bg = _estimate_bg_per_channel(img)
|
|
994
|
+
img_f = img.astype(np.float32)
|
|
995
|
+
if channels == 1:
|
|
996
|
+
deviation = np.abs(img_f - bg[0])
|
|
997
|
+
bg_lum = bg[0]
|
|
998
|
+
else:
|
|
999
|
+
bg_arr = np.asarray(bg, dtype=np.float32)
|
|
1000
|
+
deviation = np.max(np.abs(img_f - bg_arr), axis=2)
|
|
1001
|
+
bg_lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]
|
|
1002
|
+
|
|
1003
|
+
ink = deviation > _HALO_INK_DEV
|
|
1004
|
+
ink_count = int(np.count_nonzero(ink))
|
|
1005
|
+
# A page that is mostly ink would be painted blank. Leave the label alone.
|
|
1006
|
+
if ink_count == 0 or ink_count > 0.90 * h * w:
|
|
1007
|
+
return img
|
|
1008
|
+
|
|
1009
|
+
sigma = 0.5 + float(intensity) * 0.10 * float(min(h, w))
|
|
1010
|
+
ys, xs = np.nonzero(ink)
|
|
1011
|
+
y0, y1 = int(ys.min()), int(ys.max())
|
|
1012
|
+
x0, x1 = int(xs.min()), int(xs.max())
|
|
1013
|
+
gap = min(y0, x0, (h - 1) - y1, (w - 1) - x1)
|
|
1014
|
+
sigma = min(sigma, max(0.5, 0.40 * float(gap)))
|
|
1015
|
+
|
|
1016
|
+
halo_alpha = float(np.clip(0.25 + 0.55 * float(intensity), 0.0, 1.0))
|
|
1017
|
+
|
|
1018
|
+
# One draw from ``random`` so a worker seed fixes the hue. Drawn on
|
|
1019
|
+
# grayscale too, so the seed sequence does not depend on channel count.
|
|
1020
|
+
roll = random.random()
|
|
1021
|
+
if roll < 0.72:
|
|
1022
|
+
hue = 60 # green
|
|
1023
|
+
elif roll < 0.86:
|
|
1024
|
+
hue = 90 # cyan
|
|
1025
|
+
else:
|
|
1026
|
+
hue = 150 # magenta
|
|
1027
|
+
|
|
1028
|
+
blurred = cv2.GaussianBlur(
|
|
1029
|
+
ink.astype(np.float32),
|
|
1030
|
+
(0, 0),
|
|
1031
|
+
sigmaX=float(sigma),
|
|
1032
|
+
sigmaY=float(sigma),
|
|
1033
|
+
borderType=cv2.BORDER_CONSTANT,
|
|
1034
|
+
)
|
|
1035
|
+
blurred[ink] = 0.0
|
|
1036
|
+
alpha = blurred * halo_alpha
|
|
1037
|
+
|
|
1038
|
+
if channels == 1:
|
|
1039
|
+
# Near-white fill on a 1-channel light page erases the glyph. Keep the ink.
|
|
1040
|
+
out = img_f * (1.0 - alpha) + 255.0 * alpha
|
|
1041
|
+
else:
|
|
1042
|
+
hsv_pixel = np.array([[[hue, 140, 255]]], dtype=np.uint8)
|
|
1043
|
+
color = cv2.cvtColor(hsv_pixel, cv2.COLOR_HSV2RGB)[0, 0]
|
|
1044
|
+
alpha3 = alpha[..., np.newaxis]
|
|
1045
|
+
out = img_f * (1.0 - alpha3) + color.astype(np.float32) * alpha3
|
|
1046
|
+
if bg_lum >= 128.0:
|
|
1047
|
+
out[ink] = 250.0
|
|
1048
|
+
|
|
1049
|
+
return np.clip(out, 0, 255).astype(np.uint8)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
# Registry
|
|
1053
|
+
|
|
1054
|
+
# Methods that produce better results when operating on RGB input rather than grayscale.
|
|
1055
|
+
_RGB_PREFERRED_METHODS: frozenset[str] = frozenset(
|
|
1056
|
+
{
|
|
1057
|
+
"hsv",
|
|
1058
|
+
"reverse",
|
|
1059
|
+
"brightness_contrast",
|
|
1060
|
+
"gradient_illumination",
|
|
1061
|
+
"rotation",
|
|
1062
|
+
}
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
# Unified registry: all 27 augmentation methods.
|
|
1066
|
+
AUG_METHODS: dict[str, Any] = {
|
|
1067
|
+
# Generator-side (scanner/camera degradation simulation)
|
|
1068
|
+
"sauvola": apply_sauvola,
|
|
1069
|
+
"geo_warp": apply_geo_warp,
|
|
1070
|
+
"vertical_crop": apply_vertical_crop,
|
|
1071
|
+
"blur": apply_blur,
|
|
1072
|
+
"distortion": apply_distortion,
|
|
1073
|
+
"albu_noise": apply_albu_noise,
|
|
1074
|
+
"jpeg_compression": apply_jpeg_compression,
|
|
1075
|
+
"rotation": apply_rotation,
|
|
1076
|
+
"salt_pepper": apply_salt_pepper,
|
|
1077
|
+
"background_texture": apply_background_texture,
|
|
1078
|
+
"lowdpi": apply_lowdpi,
|
|
1079
|
+
"oversample": apply_oversample,
|
|
1080
|
+
"extreme_resize": apply_extreme_resize,
|
|
1081
|
+
"low_contrast_caption": apply_low_contrast_caption,
|
|
1082
|
+
# Training-time simulation
|
|
1083
|
+
"perspective": apply_perspective,
|
|
1084
|
+
"elastic": apply_elastic,
|
|
1085
|
+
"random_crop": apply_random_crop,
|
|
1086
|
+
"online_blur": apply_online_blur,
|
|
1087
|
+
"online_noise": apply_online_noise,
|
|
1088
|
+
"hsv": apply_hsv,
|
|
1089
|
+
"reverse": apply_reverse,
|
|
1090
|
+
"brightness_contrast": apply_brightness_contrast,
|
|
1091
|
+
"pixelation": apply_pixelation,
|
|
1092
|
+
"gradient_illumination": apply_gradient_illumination,
|
|
1093
|
+
"morphological": apply_morphological,
|
|
1094
|
+
"anisotropic_dilation": apply_anisotropic_dilation,
|
|
1095
|
+
"luminous_halo": apply_luminous_halo,
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
# Replace with Rust-accelerated implementations when available
|
|
1099
|
+
|
|
1100
|
+
_RUST_METHODS = [
|
|
1101
|
+
"sauvola",
|
|
1102
|
+
"geo_warp",
|
|
1103
|
+
"vertical_crop",
|
|
1104
|
+
"blur",
|
|
1105
|
+
"jpeg_compression",
|
|
1106
|
+
"rotation",
|
|
1107
|
+
"salt_pepper",
|
|
1108
|
+
"background_texture",
|
|
1109
|
+
"lowdpi",
|
|
1110
|
+
"oversample",
|
|
1111
|
+
"perspective",
|
|
1112
|
+
"elastic",
|
|
1113
|
+
"random_crop",
|
|
1114
|
+
"online_blur",
|
|
1115
|
+
"online_noise",
|
|
1116
|
+
"hsv",
|
|
1117
|
+
"reverse",
|
|
1118
|
+
"brightness_contrast",
|
|
1119
|
+
"pixelation",
|
|
1120
|
+
# NOTE: gradient_illumination excluded;
|
|
1121
|
+
# Rust is grayscale-only but the rendering pipeline passes RGB for _RGB_PREFERRED_METHODS members.
|
|
1122
|
+
"morphological",
|
|
1123
|
+
"anisotropic_dilation",
|
|
1124
|
+
]
|
|
1125
|
+
|
|
1126
|
+
# Grayscale-only Rust methods that lack a native RGB variant. When the pipeline
|
|
1127
|
+
# runs in RGB color-mode the clean canvas is a HxWx3 array, but these native
|
|
1128
|
+
# functions accept only a 2D (HxW) uint8 array -- passing RGB raises the PyO3
|
|
1129
|
+
# error "'ndarray' object cannot be converted to 'PyArray<T, D>'". Wrap them so
|
|
1130
|
+
# RGB input is collapsed to grayscale, transformed once, and expanded back to
|
|
1131
|
+
# RGB. Applying the kernel per-channel would be wrong for the many methods that
|
|
1132
|
+
# draw fresh randomness each call (perspective, elastic, geo_warp, salt_pepper,
|
|
1133
|
+
# random_crop, ...): each channel would get a different transform,
|
|
1134
|
+
# desynchronising the channels and introducing chromatic fringing. A single
|
|
1135
|
+
# grayscale pass keeps the three channels consistent. (The RGB-preferred methods
|
|
1136
|
+
# -- hsv, reverse, brightness_contrast, rotation -- are already RGB-aware
|
|
1137
|
+
# wrappers exported by _rust_accel, drawing one shared random draw and applying
|
|
1138
|
+
# it to all channels together, and are excluded here.)
|
|
1139
|
+
_GRAYSCALE_ONLY_RUST_METHODS: frozenset[str] = frozenset(_RUST_METHODS) - _RGB_PREFERRED_METHODS
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _wrap_grayscale_rust_fn(rust_fn: Any) -> Any:
|
|
1143
|
+
"""Wrap a 2D-only Rust augmentation so RGB input is handled via grayscale.
|
|
1144
|
+
|
|
1145
|
+
RGB (HxWx3) arrays are converted to a single grayscale plane, passed through
|
|
1146
|
+
the native 2D kernel once, then broadcast back to 3 channels. Grayscale
|
|
1147
|
+
(HxW) input is passed straight through.
|
|
1148
|
+
"""
|
|
1149
|
+
|
|
1150
|
+
def _wrapper(img: np.ndarray, intensity: float, **kwargs: Any) -> np.ndarray | None:
|
|
1151
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
1152
|
+
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
|
1153
|
+
result = rust_fn(np.ascontiguousarray(gray), intensity)
|
|
1154
|
+
if result is None:
|
|
1155
|
+
return None
|
|
1156
|
+
if result.ndim == 2:
|
|
1157
|
+
return cv2.cvtColor(result, cv2.COLOR_GRAY2RGB)
|
|
1158
|
+
return result
|
|
1159
|
+
return rust_fn(np.ascontiguousarray(img), intensity)
|
|
1160
|
+
|
|
1161
|
+
return _wrapper
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
PYTHON_AUG_METHODS: dict[str, Any] = dict(AUG_METHODS)
|
|
1165
|
+
_RUST_ACCEL_FUNCTIONS: set[Any] = set()
|
|
1166
|
+
try:
|
|
1167
|
+
from . import _rust_accel as _ra # type: ignore[import-untyped,assignment]
|
|
1168
|
+
|
|
1169
|
+
if _ra.HAS_RUST_ACCEL:
|
|
1170
|
+
_replaced = 0
|
|
1171
|
+
for _method in _RUST_METHODS:
|
|
1172
|
+
_rust_fn = getattr(_ra, f"apply_{_method}", None)
|
|
1173
|
+
if _rust_fn is not None and _method in AUG_METHODS:
|
|
1174
|
+
if _method in _GRAYSCALE_ONLY_RUST_METHODS:
|
|
1175
|
+
fn = _wrap_grayscale_rust_fn(_rust_fn)
|
|
1176
|
+
else:
|
|
1177
|
+
fn = _rust_fn
|
|
1178
|
+
_RUST_ACCEL_FUNCTIONS.add(fn)
|
|
1179
|
+
AUG_METHODS[_method] = fn
|
|
1180
|
+
_replaced += 1
|
|
1181
|
+
if _replaced:
|
|
1182
|
+
_LOGGER.info(
|
|
1183
|
+
"Rust acceleration: %d/%d augmentation methods using native code",
|
|
1184
|
+
_replaced,
|
|
1185
|
+
len(_RUST_METHODS),
|
|
1186
|
+
)
|
|
1187
|
+
except ImportError:
|
|
1188
|
+
pass
|
|
1189
|
+
|
|
1190
|
+
del _RUST_METHODS
|
|
1191
|
+
|
|
1192
|
+
# Backward-compatibility alias.
|
|
1193
|
+
OFFLINE_AUG_METHODS = AUG_METHODS
|