From d314415503805478c53a6a2ab71f54e2210d29e8 Mon Sep 17 00:00:00 2001 From: songhyeonsu Date: Thu, 7 May 2026 17:20:05 +0900 Subject: [PATCH] Add synthetic LP generator (4 plate types, PGNet label format) - generate_synthetic.py: Type 1/2 (one-line) and Type 3/4 (two-line) supported - Hangul char map covers all 37 glyphs available in the asset bundle - Two-line plates emit two separate PGNet polygons (top region+digits, bottom char+digits) - REGION_MAP is a best-effort guess and flagged for visual verification - Optional --dict flag prints coverage diagnostics Co-Authored-By: Claude Opus 4.7 --- data_gen/README.md | 54 ++++++++ data_gen/generate_synthetic.py | 245 +++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 data_gen/README.md create mode 100644 data_gen/generate_synthetic.py diff --git a/data_gen/README.md b/data_gen/README.md new file mode 100644 index 0000000..c4c3f45 --- /dev/null +++ b/data_gen/README.md @@ -0,0 +1,54 @@ +# data_gen — 합성 LP 데이터 생성 + +## 자산 (Korean-license-plate-Generator) + +`setup_assets.sh`가 [qjadud1994/Korean-license-plate-Generator](https://github.com/qjadud1994/Korean-license-plate-Generator) (MIT)을 clone합니다. 폰트 파일 대신 **이미 렌더링된 글자 PNG + plate 배경 이미지**를 제공해서 폰트 라이센스를 회피합니다. + +자산은 이 repo에 포함되지 않고(`.gitignore`), `setup_assets.sh`로 매번 받습니다. + +## 사용법 (서버 컨테이너 안) + +```bash +# 1. 자산 다운로드 (1회) +bash /workspace/kr_lp_pgnet/data_gen/setup_assets.sh + +# 2. 합성 데이터 생성 +python3.10 /workspace/kr_lp_pgnet/data_gen/generate_synthetic.py \ + --out_dir /workspace/train_data/kr_lp_synth \ + --num 200000 \ + --types 1,2,3,4 \ + --dict /workspace/kr_lp_pgnet/dict/kr_lp_dict.txt +``` + +## 출력 구조 + +``` +out_dir/ +├── train/ +│ ├── images/000000.jpg ... +│ └── train.txt ← PaddleOCR PGNet 라벨 +└── test/ + ├── images/... + └── test.txt +``` + +라벨 한 줄 예시 (탭 구분): +``` +images/000123.jpg\t[{"transcription": "12가3456", "points": [[0,0],[520,0],[520,110],[0,110]]}] +``` + +## Plate 종류 + +| Type | 사이즈 | 배경 | 라벨 형식 | +|---|---|---|---| +| 1 | 520×110 | 흰 | `NN한NNNN` (한 줄) | +| 2 | 355×155 | 흰 (구형) | `NN한NNNN` (한 줄) | +| 3 | 336×170 | 노랑 | `지역명NN` + `한NNNN` (두 줄, polygon 분리) | +| 4 | 336×170 | 파/녹 | `지역명NN` + `한NNNN` (두 줄, polygon 분리) | + +## 알려진 제약 + +- **REGION_MAP은 추정 매핑** — `region_y/A.jpg ~ P.jpg`가 어떤 한국 지역명과 매칭되는지 정확한 정보가 자산 README에 없습니다. 합성 결과 PNG를 시각 확인 후 `generate_synthetic.py`의 `REGION_MAP`을 정정하세요. +- **자산이 못 만드는 글자**: `하`, `호`, `배`. 이 글자가 들어간 LP는 합성 데이터에 등장하지 않습니다 — Step2 fine-tune의 실차 데이터로 보충됩니다. +- **세종 지역**: 자산 region이 16개라 세종이 빠진 것으로 추정 (한국 광역지자체 17개 중 1개). 마찬가지로 Step2에서 보충. +- **plate 배경 정확도**: 자산이 모방한 배경이라 실제 한국 LP와 색상·로고 일부 차이. 학습은 글자 segmentation+분류가 핵심이라 큰 영향 없으나, Step2 fine-tune이 필수. diff --git a/data_gen/generate_synthetic.py b/data_gen/generate_synthetic.py new file mode 100644 index 0000000..c8de532 --- /dev/null +++ b/data_gen/generate_synthetic.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +한국 LP 합성 데이터 생성기 (PGNet 학습용 Step1 pretrain). + +자산 출처: qjadud1994/Korean-license-plate-Generator (MIT) +출력 라벨 포맷: PaddleOCR PGNet (PGDataSet 호환) + <상대경로>\t[{"transcription": "12가3456", "points": [[x1,y1],...,[x4,y4]]}, ...] + +지원 plate 종류: + Type 1: 신형 승용 가로 1줄 (520x110, 흰) + Type 2: 구형 승용 가로 1줄 (355x155, 흰) + Type 3: 영업용 두 줄 (336x170, 노랑) — 지역명 + 숫자2 / 한글 + 숫자4 + Type 4: 친환경/전기 두 줄 (336x170, 파랑·녹색) + +주의 — REGION_MAP은 추정 매핑입니다. 합성 결과 PNG를 시각 확인 후 정정하세요. +""" + +import argparse +import json +import random +import sys +from pathlib import Path + +import cv2 +import numpy as np + + +# 한영 자판 매핑 (자음·모음 → 영문 두 글자 코드 → 한글 글자) +# qjadud1994 자산의 char1/char1_g/char1_y 폴더 파일명 규칙과 일치 (37자). +HANGUL_CHAR_MAP = { + 'ah': '모', 'aj': '머', 'ak': '마', 'an': '무', + 'dh': '오', 'dj': '어', 'dk': '아', 'dn': '우', + 'eh': '도', 'ej': '더', 'ek': '다', 'en': '두', + 'fh': '로', 'fj': '러', 'fk': '라', 'fn': '루', + 'gj': '허', + 'qh': '보', 'qj': '버', 'qk': '바', 'qn': '부', + 'rh': '고', 'rj': '거', 'rk': '가', 'rn': '구', + 'sh': '노', 'sj': '너', 'sk': '나', 'sn': '누', + 'th': '소', 'tj': '서', 'tk': '사', 'tn': '수', + 'wh': '조', 'wj': '저', 'wk': '자', 'wn': '주', +} + +# 자산 region_y / region_g 의 알파벳 코드 → 한국 광역지자체 (16종, 세종 제외 추정). +# 정확한 매핑은 PNG 시각 확인 필요. 학습 노이즈 방지 위해 합성 후 검증할 것. +REGION_MAP = { + 'A': '서울', 'B': '경기', 'C': '인천', 'D': '강원', + 'E': '충남', 'F': '대전', 'G': '충북', 'H': '부산', + 'I': '울산', 'J': '대구', 'K': '경북', 'L': '경남', + 'M': '전남', 'N': '광주', 'O': '전북', 'P': '제주', +} + + +class LPGenerator: + def __init__(self, asset_dir: Path): + self.asset = Path(asset_dir) + if not self.asset.is_dir(): + raise FileNotFoundError(f"asset dir not found: {self.asset}. Run data_gen/setup_assets.sh first.") + + self.plate_w = cv2.imread(str(self.asset / "plate.jpg")) + self.plate_y = cv2.imread(str(self.asset / "plate_y.jpg")) + self.plate_g = cv2.imread(str(self.asset / "plate_g.jpg")) + + self.num_w = self._load("num") + self.num_y = self._load("num_y") + self.num_g = self._load("num_g") + self.char_w = self._load("char1") + self.char_y = self._load("char1_y") + self.char_g = self._load("char1_g") + self.region_y_imgs = self._load("region_y") + self.region_g_imgs = self._load("region_g") + + def _load(self, sub: str) -> dict: + out = {} + for fp in sorted((self.asset / sub).iterdir()): + if fp.suffix.lower() in {'.jpg', '.png'}: + out[fp.stem] = cv2.imread(str(fp)) + return out + + @staticmethod + def _resize_dict(d: dict, w: int, h: int) -> dict: + return {k: cv2.resize(v, (w, h)) for k, v in d.items()} + + def gen_type1(self): + plate = cv2.resize(self.plate_w, (520, 110)) + num = self._resize_dict(self.num_w, 56, 83) + char = self._resize_dict(self.char_w, 60, 83) + + d = [random.choice('0123456789') for _ in range(2)] + ch = random.choice(list(HANGUL_CHAR_MAP)) + e = [random.choice('0123456789') for _ in range(4)] + + row, col = 13, 35 + for x in d: + plate[row:row+83, col:col+56] = num[x]; col += 56 + plate[row:row+83, col:col+60] = char[ch]; col += 60 + 36 + for x in e: + plate[row:row+83, col:col+56] = num[x]; col += 56 + + text = ''.join(d) + HANGUL_CHAR_MAP[ch] + ''.join(e) + return plate, text + + def gen_type2(self): + plate = cv2.resize(self.plate_w, (355, 155)) + num = self._resize_dict(self.num_w, 45, 83) + char = self._resize_dict(self.char_w, 49, 70) + + d = [random.choice('0123456789') for _ in range(2)] + ch = random.choice(list(HANGUL_CHAR_MAP)) + e = [random.choice('0123456789') for _ in range(4)] + + row, col = 46, 10 + plate[row:row+83, col:col+45] = num[d[0]]; col += 45 + plate[row:row+83, col:col+45] = num[d[1]]; col += 45 + plate[row+12:row+82, col+2:col+51] = char[ch]; col += 51 + plate[row:row+83, col+2:col+47] = num[e[0]]; col += 47 + for x in e[1:]: + plate[row:row+83, col:col+45] = num[x]; col += 45 + + text = ''.join(d) + HANGUL_CHAR_MAP[ch] + ''.join(e) + return plate, text + + def _gen_two_line(self, plate_bg, num_src, char_src, region_src): + plate = cv2.resize(plate_bg, (336, 170)) + num1 = self._resize_dict(num_src, 44, 60) + num2 = self._resize_dict(num_src, 64, 90) + region = self._resize_dict(region_src, 88, 60) + char = self._resize_dict(char_src, 64, 62) + + rkey = random.choice(list(region)) + d = [random.choice('0123456789') for _ in range(2)] + ch = random.choice(list(HANGUL_CHAR_MAP)) + e = [random.choice('0123456789') for _ in range(4)] + + row, col = 8, 76 + plate[row:row+60, col:col+88] = region[rkey]; col += 88 + 8 + for x in d: + plate[row:row+60, col:col+44] = num1[x]; col += 44 + + row, col = 72, 8 + plate[row:row+62, col:col+64] = char[ch]; col += 64 + for x in e: + plate[row:row+90, col:col+64] = num2[x]; col += 64 + + # transcription: 줄 단위로 분리 (PGNet은 두 polygon으로 라벨링하는 게 정석) + top = REGION_MAP.get(rkey, '?') + ''.join(d) + bot = HANGUL_CHAR_MAP[ch] + ''.join(e) + return plate, top, bot + + def gen_type3(self): + plate, top, bot = self._gen_two_line(self.plate_y, self.num_y, self.char_y, self.region_y_imgs) + return plate, top, bot + + def gen_type4(self): + plate, top, bot = self._gen_two_line(self.plate_g, self.num_g, self.char_g, self.region_g_imgs) + return plate, top, bot + + +def make_label_one_line(plate, text): + """가로 한 줄 LP — polygon은 plate 전체.""" + h, w = plate.shape[:2] + poly = [[0, 0], [w, 0], [w, h], [0, h]] + return [{"transcription": text, "points": poly}] + + +def make_label_two_line(plate, top, bot): + """두 줄 LP — 위·아래 두 polygon 으로 분리. + 위 줄 0~50% (region+num), 아래 줄 50~100% (char+num*4) — 단순 분할.""" + h, w = plate.shape[:2] + mid = h // 2 + up_poly = [[0, 0], [w, 0], [w, mid], [0, mid]] + dn_poly = [[0, mid], [w, mid], [w, h], [0, h]] + return [ + {"transcription": top, "points": up_poly}, + {"transcription": bot, "points": dn_poly}, + ] + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--asset_dir", default=str(Path(__file__).parent / "Korean-license-plate-Generator")) + p.add_argument("--out_dir", required=True, help="합성 데이터셋 출력 루트") + p.add_argument("--num", type=int, default=200, help="총 이미지 개수") + p.add_argument("--test_ratio", type=float, default=0.05) + p.add_argument("--types", default="1,2,3,4", help="포함할 type (콤마 구분)") + p.add_argument("--dict", default=None, help="검증용 dict 경로 (선택)") + p.add_argument("--seed", type=int, default=42) + args = p.parse_args() + + random.seed(args.seed) + np.random.seed(args.seed) + + gen = LPGenerator(Path(args.asset_dir)) + type_funcs = { + '1': ('one', gen.gen_type1), + '2': ('one', gen.gen_type2), + '3': ('two', gen.gen_type3), + '4': ('two', gen.gen_type4), + } + chosen = [type_funcs[t.strip()] for t in args.types.split(',') if t.strip() in type_funcs] + if not chosen: + sys.exit("No valid types selected.") + + seen_chars = set() + out = Path(args.out_dir) + n_test = max(1, int(args.num * args.test_ratio)) + n_train = args.num - n_test + + for split, count in [("train", n_train), ("test", n_test)]: + img_dir = out / split / "images" + img_dir.mkdir(parents=True, exist_ok=True) + records = [] + for i in range(count): + kind, fn = random.choice(chosen) + if kind == 'one': + plate, text = fn() + label = make_label_one_line(plate, text) + seen_chars.update(text) + else: + plate, top, bot = fn() + label = make_label_two_line(plate, top, bot) + seen_chars.update(top); seen_chars.update(bot) + fname = f"{i:06d}.jpg" + cv2.imwrite(str(img_dir / fname), plate) + records.append((f"images/{fname}", json.dumps(label, ensure_ascii=False))) + + with open(out / split / f"{split}.txt", "w", encoding="utf-8") as f: + for path, lab in records: + f.write(f"{path}\t{lab}\n") + print(f" {split}: {len(records)} images → {out / split}") + + # dict 검증 + if args.dict: + with open(args.dict, encoding="utf-8") as f: + dict_chars = {ln.strip() for ln in f if ln.strip()} + missing = seen_chars - dict_chars - {'?'} + unused = dict_chars - seen_chars + print() + print(f"dict 검증 (in {args.dict}):") + print(f" 생성에 등장한 글자 {len(seen_chars)}자") + print(f" dict 누락 (라벨에 있는데 dict에 없음): {sorted(missing) or 'none'}") + print(f" 미등장 (dict에 있는데 합성 데이터에 없음): {sorted(unused) or 'none'}") + + +if __name__ == "__main__": + main()