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 <noreply@anthropic.com>
main
songhyeonsu 1 month ago
parent ffdc77dec0
commit d314415503

@ -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이 필수.

@ -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()
Loading…
Cancel
Save