askakalsky commited on
Commit
f74dbe3
·
verified ·
1 Parent(s): 930da1b

Upload train/generate_sequences.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. train/generate_sequences.py +383 -0
train/generate_sequences.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate synthetic 8-character passport sequence images for CRNN training.
3
+
4
+ Each image is a full ROI strip (2 letters + 6 digits) rendered side by side
5
+ from the dot-matrix font, matching real passport proportions (~5:1 width:height).
6
+
7
+ Output:
8
+ data/sequences/ PNG images (48 x 256 grayscale)
9
+ data/sequences/labels.json { "filename.png": "АВ123456", ... }
10
+
11
+ Usage:
12
+ python generate_sequences.py --n-samples 50000
13
+ python generate_sequences.py --n-samples 200 --show-samples
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import random
19
+ import uuid
20
+ from pathlib import Path
21
+
22
+ import cv2
23
+ import numpy as np
24
+ from PIL import Image, ImageFilter
25
+
26
+
27
+ # ── Config ─────────────────────────────────────────────────────────────
28
+
29
+ SERIES_LETTERS = list("АВЕКМНОРСТИЮ")
30
+ DIGITS = list("0123456789")
31
+ N_CHARS = 8 # 2 letters + 6 digits
32
+
33
+ SEQ_H = 48 # training image height (px)
34
+ SEQ_W = 256 # training image width (px)
35
+
36
+ OUT_DIR = Path("data/sequences")
37
+
38
+
39
+ # ── Font loading (reused from generate_from_font.py) ───────────────────
40
+
41
+ def load_font() -> dict:
42
+ import json as _json
43
+ custom = Path("font_custom.json")
44
+ if custom.exists():
45
+ raw = _json.loads(custom.read_text(encoding="utf-8"))
46
+ result = {}
47
+ all_chars = set(DIGITS + SERIES_LETTERS)
48
+ for ch, rows in raw.items():
49
+ if ch in all_chars:
50
+ result[ch] = np.array(rows, dtype=np.uint8)
51
+ if result:
52
+ return result
53
+ from font5x7_cyrillic import FONT
54
+ all_chars = set(DIGITS + SERIES_LETTERS)
55
+ return {ch: grid for ch, grid in FONT.items() if ch in all_chars}
56
+
57
+
58
+ # ── Sequence renderer ──────────────────────────────────────────────────
59
+
60
+ def render_sequence(seq: str, font_data: dict,
61
+ jitter: bool = True,
62
+ canvas_h: int = SEQ_H,
63
+ canvas_w: int = SEQ_W) -> np.ndarray:
64
+ """
65
+ Render an 8-character sequence on a (canvas_h x canvas_w) canvas.
66
+
67
+ Characters are placed side by side with small inter-char gaps,
68
+ centred both horizontally and vertically.
69
+ Returns float32 array, 0=ink, 255=paper.
70
+ """
71
+ bg = random.uniform(218, 248) if jitter else 235.0
72
+ canvas = np.full((canvas_h, canvas_w), bg, dtype=np.float32)
73
+
74
+ # ── Compute layout ───────────────────────────────────────────────
75
+ char_rows = 6 # all font chars are 6 rows tall
76
+ char_ws = [font_data[c].shape[1] for c in seq] # dot columns per char
77
+
78
+ # Gap between chars: random fraction of 1 dot column
79
+ gap_dots = random.uniform(0.3, 1.2) if jitter else 0.7
80
+
81
+ total_dots_w = sum(char_ws) + gap_dots * (len(seq) - 1)
82
+
83
+ # Cell size: largest that fits both axes
84
+ cell_from_h = canvas_h * random.uniform(0.60, 0.80) / char_rows \
85
+ if jitter else canvas_h * 0.70 / char_rows
86
+ cell_from_w = canvas_w * random.uniform(0.82, 0.92) / total_dots_w \
87
+ if jitter else canvas_w * 0.88 / total_dots_w
88
+ cell = min(cell_from_h, cell_from_w)
89
+
90
+ gap_px = gap_dots * cell
91
+ render_w = sum(cw * cell for cw in char_ws) + gap_px * (len(seq) - 1)
92
+ render_h = char_rows * cell
93
+
94
+ # Random vertical/horizontal offset within the canvas
95
+ max_dx = max(0, (canvas_w - render_w) * 0.4)
96
+ max_dy = max(0, (canvas_h - render_h) * 0.4)
97
+ off_x = (canvas_w - render_w) / 2.0 + (random.uniform(-max_dx, max_dx) if jitter else 0)
98
+ off_y = (canvas_h - render_h) / 2.0 + (random.uniform(-max_dy, max_dy) if jitter else 0)
99
+
100
+ # Dot style: solid or donut (white center, like real perforations)
101
+ donut = jitter and random.random() < 0.45
102
+ hole_frac = random.uniform(0.30, 0.55) if donut else 0.0
103
+ # Dot size: vary smaller to larger
104
+ r_factor = random.uniform(0.22, 0.40) if jitter else 0.35
105
+
106
+ # ── Draw each character ──────────────────────────────────────────
107
+ x_cur = off_x
108
+ for ch in seq:
109
+ grid = font_data[ch]
110
+ H, W = grid.shape
111
+ base_r = cell * r_factor
112
+ radius = max(1.5, base_r)
113
+
114
+ for r in range(H):
115
+ for c in range(W):
116
+ if grid[r, c] == 0:
117
+ continue
118
+ cx = x_cur + (c + 0.5) * cell
119
+ cy = off_y + (r + 0.5) * cell
120
+ ink = random.uniform(10, 60) if jitter else 30.0
121
+ icx, icy = int(round(cx)), int(round(cy))
122
+ # Outer dot
123
+ cv2.circle(canvas, (icx, icy), int(round(radius)),
124
+ ink, -1, lineType=cv2.LINE_AA)
125
+ # Inner hole (donut effect)
126
+ if donut and hole_frac > 0:
127
+ hole_r = max(1, int(round(radius * hole_frac)))
128
+ hole_brightness = random.uniform(bg * 0.85, bg * 1.05) if jitter else bg
129
+ cv2.circle(canvas, (icx, icy), hole_r,
130
+ min(255, hole_brightness), -1, lineType=cv2.LINE_AA)
131
+ x_cur += W * cell + gap_px
132
+
133
+ return canvas
134
+
135
+
136
+ # ── Augmentation (whole-strip, same functions as generate_from_font.py) ─
137
+
138
+ def aug_perspective(img: np.ndarray, strength: float = 0.07) -> np.ndarray:
139
+ h, w = img.shape
140
+ d = max(1, int(min(h, w) * strength))
141
+ src = np.float32([[0, 0], [w, 0], [w, h], [0, h]])
142
+ def jit(): return random.randint(-d, d)
143
+ dst = np.float32([
144
+ [jit(), jit()], [w + jit(), jit()],
145
+ [w + jit(), h + jit()], [jit(), h + jit()],
146
+ ])
147
+ M = cv2.getPerspectiveTransform(src, dst)
148
+ fill = float(img[0, 0])
149
+ return cv2.warpPerspective(img, M, (w, h),
150
+ borderMode=cv2.BORDER_CONSTANT,
151
+ borderValue=fill).astype(np.float32)
152
+
153
+
154
+ def aug_rotation(img: np.ndarray, angle: float) -> np.ndarray:
155
+ h, w = img.shape
156
+ M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
157
+ fill = float(img[0, 0])
158
+ return cv2.warpAffine(img, M, (w, h),
159
+ borderMode=cv2.BORDER_CONSTANT,
160
+ borderValue=fill).astype(np.float32)
161
+
162
+
163
+ def aug_shadow_stripe(img: np.ndarray, prob: float = 0.40) -> np.ndarray:
164
+ """
165
+ Bright horizontal band at top and/or bottom (light reflection / shadow edge).
166
+ Applied AFTER rotation so the stripe is always horizontal in the frame.
167
+ """
168
+ if random.random() > prob:
169
+ return img
170
+ out = img.astype(np.float32).copy()
171
+ h, w = out.shape
172
+ sides = random.choice(['top', 'bottom', 'both'])
173
+ for side in (['top', 'bottom'] if sides == 'both' else [sides]):
174
+ size = int(h * random.uniform(0.15, 0.45))
175
+ bright = random.uniform(210, 255)
176
+ for row in range(size):
177
+ if side == 'top':
178
+ alpha = 1.0 - row / size
179
+ r = row
180
+ else:
181
+ alpha = row / size
182
+ r = h - size + row
183
+ out[r, :] = out[r, :] * (1 - alpha) + bright * alpha
184
+ return np.clip(out, 0, 255).astype(np.float32)
185
+
186
+
187
+ def aug_illumination(img: np.ndarray) -> np.ndarray:
188
+ h, w = img.shape
189
+ angle = random.uniform(0, 2 * np.pi)
190
+ gx = np.cos(angle) * np.linspace(-1, 1, w)
191
+ gy = np.sin(angle) * np.linspace(-1, 1, h)[:, None]
192
+ grad = 1.0 + (gx + gy) * random.uniform(0.03, 0.20)
193
+ return np.clip(img * grad, 0, 255).astype(np.float32)
194
+
195
+
196
+ def aug_noise(img: np.ndarray) -> np.ndarray:
197
+ sigma = random.uniform(20, 65)
198
+ # Occasional heavy-damage frame
199
+ if random.random() < 0.15:
200
+ sigma = random.uniform(60, 110)
201
+ return np.clip(img + np.random.normal(0, sigma, img.shape).astype(np.float32),
202
+ 0, 255).astype(np.float32)
203
+
204
+
205
+ def aug_speckles(img: np.ndarray, prob: float = 0.55) -> np.ndarray:
206
+ if random.random() > prob:
207
+ return img
208
+ out = img.copy()
209
+ h, w = out.shape
210
+ for _ in range(random.randint(3, 14)):
211
+ cx = random.randint(0, w - 1)
212
+ cy = random.randint(0, h - 1)
213
+ r = random.randint(1, 8)
214
+ cv2.circle(out, (cx, cy), r, random.uniform(5, 100), -1,
215
+ lineType=cv2.LINE_AA)
216
+ return out.astype(np.float32)
217
+
218
+
219
+ def aug_blur(img: np.ndarray) -> np.ndarray:
220
+ r = random.uniform(0, 1.2)
221
+ if r > 0.15:
222
+ pil = Image.fromarray(img.astype(np.uint8))
223
+ pil = pil.filter(ImageFilter.GaussianBlur(radius=r))
224
+ return np.array(pil, dtype=np.float32)
225
+ return img
226
+
227
+
228
+ def find_text_bbox(img: np.ndarray, margin: int = 8):
229
+ """
230
+ Find tight bounding box around text dots (dark on light background).
231
+ Works on clean rendered image BEFORE noise is added.
232
+ Returns (x1, y1, x2, y2).
233
+ """
234
+ h, w = img.shape
235
+ mask = img < 160 # text pixels are dark, background is ~220-248
236
+ rows = np.any(mask, axis=1)
237
+ cols = np.any(mask, axis=0)
238
+ if not rows.any() or not cols.any():
239
+ return 0, 0, w, h
240
+ y1 = max(0, int(np.where(rows)[0][0]) - margin)
241
+ y2 = min(h - 1, int(np.where(rows)[0][-1]) + margin)
242
+ x1 = max(0, int(np.where(cols)[0][0]) - margin)
243
+ x2 = min(w - 1, int(np.where(cols)[0][-1]) + margin)
244
+ return x1, y1, x2, y2
245
+
246
+
247
+ def full_augment_sequence(seq: str, font_data: dict) -> np.ndarray:
248
+ """
249
+ Render + augment -> uint8 (SEQ_H x SEQ_W).
250
+
251
+ Uses a large render canvas + bbox detection to guarantee
252
+ no character is ever clipped, regardless of rotation or perspective.
253
+
254
+ Flow:
255
+ 1. Render on large canvas (big enough for any 20° rotation).
256
+ 2. Apply perspective + rotation (no noise yet — image is clean).
257
+ 3. Detect text bounding box by thresholding.
258
+ 4. Crop to bbox, resize to SEQ_W x SEQ_H.
259
+ 5. Apply photometric augmentation (noise, blur, etc.).
260
+ 6. Add horizontal shadow stripe.
261
+ 7. Optionally invert (dark bg / light dots).
262
+ """
263
+ # Large canvas: 2x width, 6x height — safe for ±20° rotation of wide strip
264
+ CNAME_W = SEQ_W * 2 # 512 px
265
+ CNAME_H = SEQ_H * 6 # 288 px
266
+
267
+ angle = random.uniform(-20.0, 20.0)
268
+
269
+ # ── 1. Render on big canvas ────────────────────────────────────────
270
+ img = render_sequence(seq, font_data, jitter=True,
271
+ canvas_h=CNAME_H, canvas_w=CNAME_W)
272
+
273
+ # ── 2. Geometric transforms (clean image) ─────────────────────────
274
+ img = aug_perspective(img, strength=0.05)
275
+ img = aug_rotation(img, angle=angle)
276
+
277
+ # ── 3. Find text bbox on still-clean image ────────────────────────
278
+ x1, y1, x2, y2 = find_text_bbox(img, margin=10)
279
+ # Ensure minimum crop size to avoid degenerate resize
280
+ if (x2 - x1) < 16 or (y2 - y1) < 8:
281
+ x1, y1, x2, y2 = 0, 0, CNAME_W, CNAME_H
282
+
283
+ crop = img[y1:y2, x1:x2]
284
+
285
+ # ── 4. Resize crop to target ──────────────────────────────────────
286
+ result = cv2.resize(crop, (SEQ_W, SEQ_H), interpolation=cv2.INTER_AREA)
287
+
288
+ # ── 5. Photometric augmentation ───────────────────────────────────
289
+ result = aug_illumination(result)
290
+ result = aug_noise(result)
291
+ result = aug_speckles(result)
292
+ result = aug_blur(result)
293
+ result = np.clip(result, 0, 255).astype(np.uint8)
294
+
295
+ # ── 6. Shadow stripe (always horizontal in final frame) ───────────
296
+ result = aug_shadow_stripe(result.astype(np.float32))
297
+ result = np.clip(result, 0, 255).astype(np.uint8)
298
+
299
+ # ── 7. Inversion ──────────────────────────────────────────────────
300
+ if random.random() < 0.40:
301
+ result = 255 - result
302
+ return result
303
+
304
+
305
+ # ── Random string generation ───────────────────────────────────────────
306
+
307
+ def random_sequence() -> str:
308
+ l1 = random.choice(SERIES_LETTERS)
309
+ l2 = random.choice(SERIES_LETTERS)
310
+ digits = "".join(random.choice(DIGITS) for _ in range(6))
311
+ return l1 + l2 + digits
312
+
313
+
314
+ # ── Main generation loop ───────────────────────────────────────────────
315
+
316
+ def generate(n_samples: int, show_samples: bool = False):
317
+ font_data = load_font()
318
+ missing = [c for c in SERIES_LETTERS + DIGITS if c not in font_data]
319
+ if missing:
320
+ print(f"WARNING: characters missing from font: {missing}")
321
+
322
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
323
+ labels_path = OUT_DIR / "labels.json"
324
+
325
+ # Load existing labels so we can append if needed
326
+ if labels_path.exists():
327
+ existing = json.loads(labels_path.read_text(encoding="utf-8"))
328
+ else:
329
+ existing = {}
330
+
331
+ print(f"Generating {n_samples} sequence images -> {OUT_DIR}/")
332
+ new_labels = {}
333
+
334
+ for i in range(n_samples):
335
+ seq = random_sequence()
336
+ img = full_augment_sequence(seq, font_data)
337
+ fname = f"seq_{uuid.uuid4().hex}.png"
338
+ cv2.imwrite(str(OUT_DIR / fname), img)
339
+ new_labels[fname] = seq
340
+
341
+ if (i + 1) % 5000 == 0 or i == n_samples - 1:
342
+ print(f" {i + 1}/{n_samples}")
343
+
344
+ existing.update(new_labels)
345
+ labels_path.write_text(json.dumps(existing, ensure_ascii=False, indent=None),
346
+ encoding="utf-8")
347
+ print(f"Labels saved: {labels_path} ({len(existing)} total entries)")
348
+
349
+ if show_samples:
350
+ _show_grid(font_data)
351
+
352
+
353
+ def _show_grid(font_data: dict, n: int = 20):
354
+ import matplotlib
355
+ matplotlib.use("TkAgg")
356
+ import matplotlib.pyplot as plt
357
+
358
+ cols = 4
359
+ rows = (n + cols - 1) // cols
360
+ fig, axes = plt.subplots(rows, cols, figsize=(cols * 4, rows * 1.5))
361
+ axes_flat = axes.flatten()
362
+
363
+ for idx in range(n):
364
+ seq = random_sequence()
365
+ img = full_augment_sequence(seq, font_data)
366
+ ax = axes_flat[idx]
367
+ ax.imshow(img, cmap="gray", vmin=0, vmax=255)
368
+ ax.set_title(seq, fontsize=8)
369
+ ax.axis("off")
370
+ for idx in range(n, len(axes_flat)):
371
+ axes_flat[idx].axis("off")
372
+
373
+ plt.suptitle("Synthetic sequence samples", fontsize=10)
374
+ plt.tight_layout()
375
+ plt.show()
376
+
377
+
378
+ if __name__ == "__main__":
379
+ ap = argparse.ArgumentParser()
380
+ ap.add_argument("--n-samples", type=int, default=50000)
381
+ ap.add_argument("--show-samples", action="store_true")
382
+ args = ap.parse_args()
383
+ generate(args.n_samples, args.show_samples)