Upload train/generate_sequences.py with huggingface_hub
Browse files- 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)
|