"""Generate labeled, visibly invalid vehicle-form images without source documents. The output is ignored by Git. Each page has exact field/line ground truth, PaddleOCR detection labels, and recognition crops with page-level splits. """ from __future__ import annotations import argparse import json from pathlib import Path import random import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont TARGET_CODES = ("C.1.1", "C.1.2", "C.1.3", "B", "2.1", "2.2") CAPTIONS = { "C.1.1": "Name", "C.1.2": "Vorname", "C.1.3": "Anschrift", "B": "Erstzulassung", "2.1": "HSN", "2.2": "TSN", "A": "Kennzeichen", "D.1": "Marke", "D.2": "Typ", "D.3": "Handelsname", "E": "Fahrzeug-ID", "P.3": "Kraftstoff", "F.1": "Masse", "F.2": "Gesamtmasse", "J": "Fahrzeugklasse", "P.1": "Hubraum", "P.2": "Leistung", "S.1": "Sitzplaetze", "V.7": "CO2", "G": "Leergewicht", "15.1": "Bereifung", "15.2": "Bereifung hinten", } EXTRA_CODES = ("A", "D.1", "D.2", "D.3", "E", "P.3", "F.1", "F.2", "J", "P.1", "P.2", "S.1", "V.7", "G", "15.1", "15.2") FONT_CANDIDATES = ( "/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Narrow.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", ) SYLLABLES = ("VE", "NO", "RA", "TU", "ME", "LI", "SA", "KO", "DI", "FA", "ZEN", "BER", "LON", "FEL", "NAR", "TOV", "XEN", "MIR") def _fonts(font_path: Path | None, size: int) -> ImageFont.FreeTypeFont: candidates = ((str(font_path),) if font_path is not None else FONT_CANDIDATES) for path in candidates: try: return ImageFont.truetype(path, size) except OSError: continue if font_path is not None: raise ValueError("font unavailable") return ImageFont.load_default(size=size) def _word(rng: random.Random, *, syllables: int = 3) -> str: value = "".join(rng.choice(SYLLABLES) for _ in range(syllables)) if rng.random() < .22: value = value.replace("A", "Ä", 1) if "A" in value else value.replace("O", "Ö", 1) return value def _values(rng: random.Random) -> dict[str, list[str]]: town = _word(rng, syllables=2) + "STADT" street = _word(rng, syllables=2) + rng.choice(("WEG", "STRASSE", "ALLEE")) date = f"{rng.randint(1, 28):02d}.{rng.randint(1, 12):02d}.{rng.randint(1995, 2024)}" return { "C.1.1": [_word(rng)], "C.1.2": [_word(rng, syllables=2)], "C.1.3": [f"{street} {rng.randint(1, 199)}", f"{rng.randint(10000, 99999)} {town}"], "B": [date], "2.1": [f"{rng.randint(1000, 9999)}"], "2.2": ["".join(rng.choices("ABCDEFGHJKLMNPRSTUVWXYZ", k=3)) + f"{rng.randint(0, 999):03d}"], "A": [f"TEST-{rng.randint(1000, 9999)}"], "D.1": [_word(rng, syllables=2)], "D.2": [f"TYP-{rng.randint(100, 999)}"], "D.3": [f"MODELL-{rng.randint(100, 999)}"], "E": [f"SYNTH-ID-{rng.randint(100000, 999999)}"], "P.3": [rng.choice(("BENZIN", "DIESEL", "ELEKTRO"))], "F.1": [f"{rng.randint(1300, 4200)} KG"], "F.2": [f"{rng.randint(1300, 4200)} KG"], "J": [rng.choice(("M1", "N1", "L3E"))], "P.1": [f"{rng.randint(100, 3000)} CCM"], "P.2": [f"{rng.randint(20, 250)} KW"], "S.1": [str(rng.randint(1, 7))], "V.7": [f"{rng.randint(30, 250)} G/KM"], "G": [f"{rng.randint(900, 2400)} KG"], "15.1": [f"{rng.randint(145, 265)}/55 R{rng.randint(14, 20)}"], "15.2": [f"{rng.randint(145, 265)}/55 R{rng.randint(14, 20)}"], } def _render(seed: int, font_path: Path | None): rng = random.Random(seed) if font_path is None: installed = [Path(path) for path in FONT_CANDIDATES if Path(path).is_file()] font_path = rng.choice(installed) if installed else None hardness = seed % 3 dense = seed % 4 != 0 width, height = rng.choice(((1800, 1180), (1680, 1260), (1500, 1450))) page = Image.new("RGB", (width, height), rng.choice(((248, 247, 243), (241, 244, 247)))) draw = ImageDraw.Draw(page) value_font = _fonts(font_path, rng.randint(20, 25) if dense else rng.randint(30, 38)) small_font = _fonts(font_path, rng.randint(15, 19) if dense else rng.randint(22, 27)) header_font = _fonts(font_path, 27 if dense else 34) lines: list[dict] = [] if hardness or dense: for y in range(115, height - 95, 17 if dense else 29): draw.line((35, y, width - 35, y + (y % 13) - 6), fill=(220, 225, 228), width=1) if hardness == 2: for _ in range(1400): x, y = rng.randrange(width), rng.randrange(height) draw.point((x, y), fill=(209, 214, 216)) def line(text: str, x: int, y: int, font, *, code: str | None = None, part: str = "decoration", max_width: int | None = None) -> None: if len(text) > 25: raise ValueError("recognition label exceeds 25 characters") chosen = font if max_width is not None: while draw.textlength(text, font=chosen) > max_width and chosen.size > 19: chosen = _fonts(font_path, chosen.size - 1) ink = ((65, 70, 74), (76, 78, 80), (88, 85, 82)) if hardness == 2 else ( (28, 33, 40), (36, 39, 45), (52, 48, 47)) draw.text((x, y), text, font=chosen, fill=rng.choice(ink)) x1, y1, x2, y2 = draw.textbbox((x, y), text, font=chosen) lines.append({"text": text, "code": code, "part": part, "polygon": [[max(0, x1 - 5), max(0, y1 - 5)], [min(width - 1, x2 + 5), max(0, y1 - 5)], [min(width - 1, x2 + 5), min(height - 1, y2 + 5)], [max(0, x1 - 5), min(height - 1, y2 + 5)]]}) margin = rng.randint(65, 90) gap = rng.randint(24, 45) cell_width = (width - 2 * margin - gap) // 2 top = 130 if dense else 155 rows = 8 if dense else 5 row_height = min(205, (height - top - 100) // rows) draw.rectangle((margin - 15, 37, width - margin + 15, height - 35), outline=(120, 127, 134), width=2) line("SYNTHETISCHER TEST", margin, 54, header_font) line("FAHRZEUGDATEN", width - margin - (240 if dense else 300), 60, small_font) values = _values(rng) codes = list(TARGET_CODES) + rng.sample(EXTRA_CODES, 10 if dense else 4) rng.shuffle(codes) for index, code in enumerate(codes): column, row = index // rows, index % rows x = margin + column * (cell_width + gap) y = top + row * row_height draw.rectangle((x, y, x + cell_width, y + row_height - 12), outline=(138, 147, 151), width=rng.choice((1, 2))) stacked = rng.random() < .65 or code == "C.1.3" code_x, code_y = x + 14, y + 11 line(code, code_x, code_y, small_font, code=code, part="code") caption_x = code_x + round(draw.textlength(code, font=small_font)) + 17 line(CAPTIONS[code], caption_x, code_y, small_font, code=code, part="caption", max_width=max(90, cell_width - (caption_x - x) - 15)) value_x = x + (18 if stacked else round(cell_width * .43)) first_y = y + (39 if dense else 53) if stacked else y + 10 for offset, value in enumerate(values[code]): if not stacked and offset == 0 and value_x < caption_x + (90 if dense else 175): first_y = y + (43 if dense else 59) line(value, value_x, first_y + (28 if dense else 40) * offset, value_font, code=code, part="value", max_width=cell_width - (value_x - x) - 12) line("UNGUELTIG / TESTDATEN", margin, height - 82, small_font) src = np.float32([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]]) jitter = 20 if seed % 3 else 43 dst = np.float32([[rng.randint(0, jitter), rng.randint(0, jitter)], [width - 1 - rng.randint(0, jitter), rng.randint(0, jitter)], [width - 1 - rng.randint(0, jitter), height - 1 - rng.randint(0, jitter)], [rng.randint(0, jitter), height - 1 - rng.randint(0, jitter)]]) transform = cv2.getPerspectiveTransform(src, dst) image = cv2.warpPerspective(np.asarray(page), transform, (width, height), flags=cv2.INTER_LINEAR, borderValue=(255, 255, 255)) for item in lines: points = np.float32(item["polygon"]).reshape(-1, 1, 2) transformed = cv2.perspectiveTransform(points, transform).reshape(4, 2) item["polygon"] = [[round(float(np.clip(px, 0, width - 1))), round(float(np.clip(py, 0, height - 1)))] for px, py in transformed] yy, xx = np.mgrid[0:height, 0:width] gradient = 1 - (hardness * .07) * (xx / width) - (hardness * .04) * (yy / height) image = np.uint8(np.clip(image.astype(np.float32) * gradient[:, :, None], 0, 255)) if hardness == 2: image = cv2.GaussianBlur(image, (3, 3), .55) noise = np.random.default_rng(seed).normal(0, 2.5, image.shape) image = np.uint8(np.clip(image.astype(np.float32) + noise, 0, 255)) return image, lines, values, hardness def _crop(image: np.ndarray, polygon: list[list[int]]) -> np.ndarray: points = np.float32(polygon) width = max(16, round(max(np.linalg.norm(points[1] - points[0]), np.linalg.norm(points[2] - points[3])))) height = max(16, round(max(np.linalg.norm(points[3] - points[0]), np.linalg.norm(points[2] - points[1])))) target = np.float32([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]]) matrix = cv2.getPerspectiveTransform(points, target) return cv2.warpPerspective(image, matrix, (width, height), flags=cv2.INTER_LINEAR, borderValue=(255, 255, 255)) def generate(output: Path, *, count: int, seed: int, font_path: Path | None = None) -> dict: """Create a new directory; never read source files or overwrite a dataset.""" if count < 2: raise ValueError("count must be at least 2 for a document-level holdout") if output.exists(): raise FileExistsError(f"output already exists: {output}") (output / "pages").mkdir(parents=True) (output / "crops").mkdir() validation = set(range(count - max(1, count // 5), count)) rec = {"train": [], "val": []} det = {"train": [], "val": []} metadata = [] for index in range(count): page_id = f"synthetic-{index:05d}" split = "val" if index in validation else "train" image, lines, values, hardness = _render(seed + index * 1009, font_path) page_path = f"pages/{page_id}.jpg" quality = (92, 82, 69)[hardness] if not cv2.imwrite(str(output / page_path), cv2.cvtColor(image, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, quality]): raise OSError("could not write synthetic page") image = cv2.cvtColor(cv2.imread(str(output / page_path)), cv2.COLOR_BGR2RGB) labels = [] for number, item in enumerate(lines): crop_path = f"crops/{page_id}-{number:02d}.jpg" crop = _crop(image, item["polygon"]) if not cv2.imwrite(str(output / crop_path), cv2.cvtColor(crop, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, quality]): raise OSError("could not write synthetic crop") rec[split].append(f"{crop_path}\t{item['text']}\n") item["crop"] = crop_path labels.append({"transcription": item["text"], "points": item["polygon"]}) det[split].append(page_path + "\t" + json.dumps(labels, ensure_ascii=False) + "\n") metadata.append({"id": page_id, "split": split, "source": "fully synthetic", "image": page_path, "width": image.shape[1], "height": image.shape[0], "hardness": hardness, "fields": {code: "\n".join(values[code]) for code in TARGET_CODES}, "lines": lines}) for split in ("train", "val"): (output / f"rec_{split}.txt").write_text("".join(rec[split]), encoding="utf-8") (output / f"det_{split}.txt").write_text("".join(det[split]), encoding="utf-8") with (output / "ground_truth.jsonl").open("w", encoding="utf-8") as stream: for row in metadata: stream.write(json.dumps(row, ensure_ascii=False) + "\n") manifest = {"source": "fully synthetic", "training_on_real_documents": False, "seed": seed, "documents": count, "train_documents": count - len(validation), "validation_documents": len(validation), "recognition_crops": sum(len(items) for items in rec.values()), "target_codes": list(TARGET_CODES)} (output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") return manifest def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out", type=Path, default=Path("data/synthetic-v1")) parser.add_argument("--count", type=int, default=100) parser.add_argument("--seed", type=int, default=20261007) parser.add_argument("--font", type=Path) args = parser.parse_args() summary = generate(args.out, count=args.count, seed=args.seed, font_path=args.font) print(f"Generated {summary['documents']} synthetic pages and " f"{summary['recognition_crops']} labeled crops in {args.out}") if __name__ == "__main__": main()