"""Synthesizes realistic scoresheet-cell photos for any of the 163 classes. Design targets, derived from the real crops in data/real_crops/: * digits are large and fill most of the crop height (tight framing) * cursive right slant, overlapping kerning, variable stroke thickness * mid-gray paper with shading, blue-ink-turned-gray strokes (not black) * phone-camera artifacts: blur, sensor noise, JPEG compression * crop aspect ratio anywhere between ~1:1 and ~3.3:1 Everything is driven by an explicit `random.Random` so the validation set can be fully deterministic while training data stays infinite. """ import argparse import io import math import random import cv2 import numpy as np from PIL import Image from .classes import CLASSES, EMPTY from .glyphs import GlyphSampler # working resolution: glyphs are rendered at this digit height, the finished # cell is later downscaled to a random "photo" size (anti-aliased strokes) RENDER_DIGIT_H = 96 def _transform_mask(mask: np.ndarray, rot_deg: float, shear: float) -> np.ndarray: """Rotate + italic-shear a glyph mask on an expanded canvas.""" h, w = mask.shape pad = int(max(h, w) * 0.4) + 2 mask = np.pad(mask, pad) ph, pw = mask.shape center = (pw / 2, ph / 2) m = cv2.getRotationMatrix2D(center, rot_deg, 1.0) # italic shear: top of the glyph shifts right relative to the bottom shear_m = np.array([[1.0, -shear, shear * ph / 2], [0.0, 1.0, 0.0]]) full = np.vstack([shear_m, [0, 0, 1]]) @ np.vstack([m, [0, 0, 1]]) out = cv2.warpAffine(mask, full[:2], (pw, ph), flags=cv2.INTER_LINEAR) ys, xs = np.where(out > 0.1) if len(ys) == 0: return mask return out[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1] def _vary_thickness(mask: np.ndarray, rng: random.Random) -> np.ndarray: op = rng.choice(["dilate", "none", "dilate", "erode", "none"]) if op == "none": return mask kernel = np.ones((2, 2), np.uint8) fn = cv2.dilate if op == "dilate" else cv2.erode return fn(mask, kernel, iterations=1) def _paper(rng: random.Random, h: int, w: int) -> np.ndarray: base = rng.uniform(160, 235) img = np.full((h, w), base, np.float32) ys, xs = np.mgrid[0:h, 0:w].astype(np.float32) # linear lighting gradient gx, gy = rng.uniform(-1, 1), rng.uniform(-1, 1) plane = gx * xs / max(w, 1) + gy * ys / max(h, 1) ptp = plane.max() - plane.min() if ptp > 1e-6: img += rng.uniform(0, 22) * ((plane - plane.min()) / ptp - 0.5) # occasional soft shadow blob if rng.random() < 0.4: cx, cy = rng.uniform(0, w), rng.uniform(0, h) r = rng.uniform(0.5, 1.3) * max(w, h) d2 = ((xs - cx) ** 2 + (ys - cy) ** 2) / (r * r) img -= rng.uniform(5, 20) * np.exp(-d2) return img def _compose_ink(paper: np.ndarray, alpha: np.ndarray, rng: random.Random) -> np.ndarray: """Blend an ink alpha mask onto paper with per-pixel ink tone variation.""" ink_base = rng.uniform(30, 110) ink = ink_base + np.random.default_rng(rng.getrandbits(63)).normal( 0, 8, paper.shape ).astype(np.float32) alpha = np.clip(alpha, 0.0, 1.0) * rng.uniform(0.85, 1.0) return paper * (1 - alpha) + ink * alpha def _add_border_lines(img: np.ndarray, rng: random.Random) -> np.ndarray: """Fragment of the printed cell border caught by an imperfect crop.""" h, w = img.shape edge = rng.choice(["top", "bottom", "left", "right"]) offset = rng.randint(0, max(1, int(0.06 * min(h, w)))) darkness = rng.uniform(60, 140) thickness = rng.randint(1, 3) if edge in ("top", "bottom"): y = offset if edge == "top" else h - 1 - offset x0, x1 = sorted((rng.randint(0, w // 2), rng.randint(w // 2, w - 1))) cv2.line(img, (x0, y), (x1, y), darkness, thickness, cv2.LINE_AA) else: x = offset if edge == "left" else w - 1 - offset y0, y1 = sorted((rng.randint(0, h // 2), rng.randint(h // 2, h - 1))) cv2.line(img, (x, y0), (x, y1), darkness, thickness, cv2.LINE_AA) return img def _camera_effects(img: np.ndarray, rng: random.Random) -> np.ndarray: # optical blur img = cv2.GaussianBlur(img, (0, 0), rng.uniform(0.4, 1.4)) # occasional slight motion blur if rng.random() < 0.15: k = rng.choice([3, 5]) kernel = np.zeros((k, k), np.float32) angle = rng.uniform(0, math.pi) cv2.line( kernel, (0, int((k - 1) / 2 * (1 - math.sin(angle)))), (k - 1, int((k - 1) / 2 * (1 + math.sin(angle)))), 1.0, 1, ) kernel /= max(kernel.sum(), 1e-6) img = cv2.filter2D(img, -1, kernel) # sensor noise noise_rng = np.random.default_rng(rng.getrandbits(63)) img = img + noise_rng.normal(0, rng.uniform(1.5, 7.0), img.shape).astype(np.float32) # brightness / contrast jitter img = (img - 128.0) * rng.uniform(0.82, 1.15) + 128.0 + rng.uniform(-15, 15) img = np.clip(img, 0, 255).astype(np.uint8) # JPEG artifacts if rng.random() < 0.6: ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, rng.randint(25, 85)]) if ok: img = cv2.imdecode(buf, cv2.IMREAD_GRAYSCALE) return img def synthesize_cell( class_name: str, sampler: GlyphSampler, rng: random.Random ) -> Image.Image: """Render one cell photo for `class_name` ('11'..'99x' or 'empty').""" if class_name == EMPTY: h = rng.randint(60, 180) w = int(h * rng.uniform(1.2, 3.3)) img = _paper(rng, h, w) if rng.random() < 0.25: img = _add_border_lines(img, rng) return Image.fromarray(_camera_effects(img, rng)) # --- prepare glyph masks --- slant = rng.uniform(-0.10, 0.40) # shared cursive slant, biased rightward masks = [] for char in class_name: mask = sampler.sample(char, rng) rel_h = rng.uniform(0.5, 0.8) if char == "x" else rng.uniform(0.85, 1.15) target_h = max(8, int(RENDER_DIGIT_H * rel_h)) target_w = max(4, int(mask.shape[1] * target_h / mask.shape[0])) mask = cv2.resize(mask, (target_w, target_h), interpolation=cv2.INTER_LINEAR) mask = _transform_mask(mask, rng.uniform(-7, 7), slant + rng.uniform(-0.05, 0.05)) mask = _vary_thickness(mask, rng) masks.append(mask) gaps = [ int(rng.uniform(-0.18, 0.15) * masks[i].shape[1]) for i in range(len(masks) - 1) ] total_w = sum(m.shape[1] for m in masks) + sum(gaps) max_h = max(m.shape[0] for m in masks) # --- cell geometry: digits fill `fill` of the crop height --- fill = rng.uniform(0.45, 0.92) cell_h = int(max_h / fill) aspect = rng.uniform(0.95, 3.3) cell_w = max(int(cell_h * aspect), int(total_w * rng.uniform(1.02, 1.2))) # horizontal placement: real crops are sometimes left-aligned with empty space slack = cell_w - total_w align = rng.random() if align < 0.35: # left x = int(slack * rng.uniform(0.0, 0.15)) elif align < 0.85: # center-ish x = int(slack * rng.uniform(0.25, 0.6)) else: # right x = int(slack * rng.uniform(0.7, 0.95)) # --- compose ink alpha on full-cell canvas --- alpha = np.zeros((cell_h, cell_w), np.float32) y_center = (cell_h - max_h) / 2 for i, mask in enumerate(masks): mh, mw = mask.shape y = int(y_center + (max_h - mh) * rng.uniform(0.2, 0.8) + rng.uniform(-0.04, 0.04) * cell_h) y = min(max(y, 0), cell_h - mh) x = min(max(x, 0), cell_w - mw) region = alpha[y : y + mh, x : x + mw] np.maximum(region, mask, out=region) if i < len(gaps): x += mw + gaps[i] img = _compose_ink(_paper(rng, cell_h, cell_w), alpha, rng) if rng.random() < 0.12: img = _add_border_lines(img, rng) # downscale to a random photo resolution (real crops are 115-260 px tall) out_h = rng.randint(48, 190) out_w = max(16, int(cell_w * out_h / cell_h)) img = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_AREA) return Image.fromarray(_camera_effects(img, rng)) def _preview(path: str, seed: int, count: int) -> None: """Render a comparison grid: the real-crop classes first, then random ones.""" sampler = GlyphSampler() rng = random.Random(seed) fixed = ["96", "77", "13x", "37", "85", "42"] * 2 names = fixed + [rng.choice(CLASSES) for _ in range(max(0, count - len(fixed)))] tile_w, tile_h, caption = 200, 100, 14 cols = 6 rows = math.ceil(len(names) / cols) sheet = Image.new("L", (cols * tile_w, rows * (tile_h + caption)), 255) from PIL import ImageDraw draw = ImageDraw.Draw(sheet) for i, name in enumerate(names): cell = synthesize_cell(name, sampler, rng).resize((tile_w, tile_h)) cx, cy = (i % cols) * tile_w, (i // cols) * (tile_h + caption) sheet.paste(cell, (cx, cy + caption)) draw.text((cx + 4, cy + 1), name, fill=0) sheet.save(path) print(f"Saved {len(names)}-cell preview to {path}") if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--preview", default="preview.png", help="output image path") parser.add_argument("--seed", type=int, default=0) parser.add_argument("--n", type=int, default=48, help="number of cells") args = parser.parse_args() _preview(args.preview, args.seed, args.n)