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.
@@ -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