
📓Google Colab에서 실습하기
이 레슨은 PyTorch/GPU가 필요합니다. 노트북을 다운로드 후 Google Colab에서 열어주세요.
학습 내용
문자 인식 모델 - CNN 기반 OCR
학습 목표
- •OCR(광학 문자 인식)의 개념과 접근 방식을 이해한다
- •한국 번호판의 문자 구조와 인식 대상 클래스를 파악한다
- •CNN 기반 문자 분류기를 설계하고 PyTorch로 구현한다
- •학습 데이터 준비부터 추론까지의 전체 과정을 완성한다
OCR이란?
비유: 사람이 글자를 읽을 때를 생각해보세요. 눈으로 글자의 모양(획, 곡선, 점)을 보고, 뇌에서 "이건 가나다의 가야"라고 판단합니다. OCR(Optical Character Recognition)은 컴퓨터가 이 과정을 흉내내는 기술입니다. 카메라가 눈, CNN이 뇌 역할을 합니다.
OCR은 이미지에서 문자를 읽어내는 기술로, 번호판 인식의 2단계에 해당합니다. 1단계(YOLO)가 번호판 위치를 찾았다면, 이제 그 영역에서 실제 글자를 읽어야 합니다.
OCR 접근 방식 비교
| 방식 | 설명 | 장점 | 단점 | 대표 모델 |
|---|---|---|---|---|
| 문자 단위 | 문자를 하나씩 분리하여 개별 인식 | 직관적, 이해 쉬움 | 문자 분리 정확도에 의존 | CNN 분류기 |
| 시퀀스 인식 | 전체 문자열을 한 번에 인식 | 분리 불필요 | 모델 복잡 | CRNN + CTC |
| Attention 기반 | 이미지에서 순차적으로 문자 읽기 | 최고 성능 | 학습 데이터 많이 필요 | Transformer OCR |
우리 프로젝트에서는 문자 단위 방식으로 시작합니다. 가장 직관적이고, 각 단계를 눈으로 확인할 수 있어 학습 목적에 적합합니다.
참고: 실제 상용 시스템에서는 CRNN + CTC 또는 Transformer 기반 모델을 주로 사용합니다. 문자 단위 방식을 이해하면 이런 고급 모델도 쉽게 이해할 수 있습니다.
한국 번호판 구조 분석
모델을 만들기 전에, 우리가 인식해야 할 대상을 정확히 분석해야 합니다.
번호판 형식별 구조
| 유형 | 형식 | 문자 구성 | 총 문자 수 |
|---|---|---|---|
| 구형 일반 | 12가 3456 | 숫자2 + 한글1 + 숫자4 | 7개 |
| 신형 일반 | 123가 4567 | 숫자3 + 한글1 + 숫자4 | 8개 |
| 영업용 | 서울 12바 3456 | 한글2 + 숫자2 + 한글1 + 숫자4 | 9개 |
인식 대상 클래스 정의
python# 한국 번호판에서 인식해야 할 모든 문자 digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] # 번호판에 사용되는 한글 문자 hangul_chars = [ "가", "나", "다", "라", "마", "바", "사", "아", "자", "거", "너", "더", "러", "머", "버", "서", "어", "저", "고", "노", "도", "로", "모", "보", "소", "오", "조", "구", "누", "두", "루", "무", "부", "수", "우", "주", "허", "하", "호", "배" ] all_classes = digits + hangul_chars num_classes = len(all_classes) print(f"=== 인식 클래스 분석 ===") print(f"숫자: {len(digits)}개 - {digits}") print(f"한글: {len(hangul_chars)}개") print(f"총 클래스: {num_classes}개") # 한글 문자의 시각적 유사도 주의 similar_pairs = [ ("가", "거"), ("나", "너"), ("다", "더"), ("바", "버"), ("사", "서"), ("아", "어"), ] print() print(f"시각적으로 유사한 문자 쌍 (주의 필요):") for a, b in similar_pairs: print(f" {a} vs {b}")
CNN 문자 분류기 설계
왜 CNN인가?
| 모델 | 이미지 특징 추출 | 파라미터 효율 | 학습 난이도 |
|---|---|---|---|
| Fully Connected (FC) | 약함 (공간 정보 손실) | 매우 많음 | 쉬움 |
| CNN | 강함 (필터로 특징 추출) | 적음 (가중치 공유) | 보통 |
| Vision Transformer | 매우 강함 | 많음 | 어려움 (데이터 많이 필요) |
비유: CNN의 필터는 돋보기와 같습니다. 첫 번째 돋보기(레이어)로는 선과 모서리를 보고, 두 번째 돋보기로는 획과 곡선의 조합을 보고, 세 번째 돋보기로는 전체 글자 모양을 파악합니다. 단계별로 점점 더 복잡한 패턴을 인식하는 것이죠.
모델 아키텍처 상세
입력: 1 x 32 x 32 (그레이스케일, 32x32 픽셀)
|
v
[Conv Block 1] Conv2d(1->32, 3x3) -> BN -> ReLU -> MaxPool(2x2)
| 출력: 32 x 16 x 16
v
[Conv Block 2] Conv2d(32->64, 3x3) -> BN -> ReLU -> MaxPool(2x2)
| 출력: 64 x 8 x 8
v
[Conv Block 3] Conv2d(64->128, 3x3) -> BN -> ReLU -> MaxPool(2x2)
| 출력: 128 x 4 x 4
v
[Flatten] 128 x 4 x 4 = 2048개 값
|
v
[FC Block] Linear(2048->256) -> ReLU -> Dropout(0.5)
|
v
[Output] Linear(256->50) -> Softmax (50개 클래스 확률)
PyTorch 구현
import torch
import torch.nn as nn
class CharClassifier(nn.Module):
"""한국 번호판 문자 분류기"""
def __init__(self, num_classes=50):
super().__init__()
# 특징 추출부 (Convolutional Layers)
self.features = nn.Sequential(
# Block 1: 32x32 -> 16x16
nn.Conv2d(1, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2),
# Block 2: 16x16 -> 8x8
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2),
# Block 3: 8x8 -> 4x4
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2),
)
# 분류부 (Fully Connected Layers)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(128 * 4 * 4, 256),
nn.ReLU(inplace=True),
nn.Dropout(0.5), # 과적합 방지
nn.Linear(256, num_classes),
)
def forward(self, x):
x = self.features(x) # 특징 추출
x = self.classifier(x) # 분류
return x
# 모델 생성 및 파라미터 수 확인
model = CharClassifier(num_classes=50)
total_params = sum(p.numel() for p in model.parameters())
print(f"총 파라미터 수: {total_params:,}개")
각 레이어의 역할
| 레이어 | 역할 | 비유 |
|---|---|---|
| Conv2d | 이미지에서 특징(패턴) 추출 | 돋보기로 세부 관찰 |
| BatchNorm | 학습 안정화, 수렴 가속 | 체온 조절 (값의 범위 유지) |
| ReLU | 비선형 활성화 함수 | "의미 있는 신호만 통과" |
| MaxPool | 공간 크기 축소, 핵심 특징 유지 | 핵심만 요약하기 |
| Dropout | 무작위 뉴런 비활성화 (과적합 방지) | 시험 전 다양한 문제 풀기 |
| Linear | 최종 분류 결정 | 종합 판단 |
문자 분리 (Character Segmentation)
번호판 이미지에서 개별 문자를 분리하는 것은 문자 단위 인식의 핵심 전처리입니다.
python⚠️ 로컬 실행 필요import cv2 import numpy as np def segment_characters(plate_image): """번호판 이미지에서 개별 문자 분리""" # 1. 그레이스케일 변환 gray = cv2.cvtColor(plate_image, cv2.COLOR_BGR2GRAY) # 2. 가우시안 블러 (노이즈 제거) blurred = cv2.GaussianBlur(gray, (5, 5), 0) # 3. 적응형 이진화 (조명 변화에 강건) binary = cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2 ) # 4. 컨투어 검출 contours, _ = cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 5. 문자 크기 필터링 char_images = [] h_plate = plate_image.shape[0] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) # 너무 작거나 큰 영역 제외 if h > h_plate * 0.3 and w > 5 and w < h * 2: char_img = binary[y:y+h, x:x+w] char_img = cv2.resize(char_img, (32, 32)) char_images.append((x, char_img)) # 6. x좌표 순서로 정렬 (왼쪽 -> 오른쪽) char_images.sort(key=lambda item: item[0]) return [img for _, img in char_images]
문자 분리가 어려운 경우
| 문제 상황 | 원인 | 대처 방법 |
|---|---|---|
| 문자가 붙어 있음 | 이미지 품질 낮음 | 모폴로지 연산으로 분리 |
| 문자 일부 잘림 | 크롭 영역 부정확 | 바운딩 박스에 여유 마진 추가 |
| 그림자/반사 | 조명 문제 | 적응형 이진화 사용 |
| 나사/볼트 잡음 | 번호판 구조물 | 크기 기반 필터링 |
학습 데이터 준비
합성 데이터 생성
실제 문자 데이터가 부족할 때 프로그래밍으로 생성할 수 있습니다.
참고: 아래는 합성 데이터 생성 개념을 시뮬레이션한 예제입니다. 실제 이미지 생성은 PIL(Pillow) 라이브러리가 필요합니다.
pythonimport numpy as np np.random.seed(42) def generate_synthetic_data(num_chars=5, samples_per_char=3): """Generate synthetic character image data""" dataset = [] char_labels = ["0", "1", "2", "A", "B"] for idx, char in enumerate(char_labels[:num_chars]): for sample in range(samples_per_char): # 32x32 white background img = np.ones((32, 32)) * 255 # Simulate character region (dark area in center) cx = 16 + np.random.randint(-2, 3) cy = 16 + np.random.randint(-2, 3) for dx in range(-8, 9): for dy in range(-8, 9): x, y = cx + dx, cy + dy if 0 <= x < 32 and 0 <= y < 32: dist = abs(dx) + abs(dy) if dist < 10: img[y, x] = np.random.randint(0, 50) # Add noise noise = np.random.normal(0, 10, img.shape) img = np.clip(img + noise, 0, 255).astype(np.uint8) dataset.append((img, char)) return dataset # Generate dataset dataset = generate_synthetic_data(num_chars=5, samples_per_char=3) print("=== Synthetic Data Generation ===") print(f"Total samples: {len(dataset)}") print(f"Characters: ['0', '1', '2', 'A', 'B']") print() for i, (img, label) in enumerate(dataset[:5]): print(f"Sample {i+1}: char='{label}', shape={img.shape}") print(f" Pixel range: {img.min()} ~ {img.max()}") print(f" Mean brightness: {img.mean():.1f}")
클래스 균형 확인
pythonimport numpy as np # 실제 번호판에서 문자 출현 빈도는 균등하지 않습니다 # 예시: 일부 문자는 매우 자주, 일부는 드물게 등장 char_counts = { "0": 850, "1": 920, "2": 780, "3": 810, "4": 750, "5": 690, "6": 720, "7": 830, "8": 770, "9": 800, "가": 450, "나": 380, "다": 320, "라": 250, "마": 200, "바": 420, "사": 350, "아": 280, "허": 50, "호": 80, "배": 30, } values = list(char_counts.values()) print("=== 클래스 불균형 분석 ===") print(f"최다 클래스: {max(char_counts, key=char_counts.get)} ({max(values)}개)") print(f"최소 클래스: {min(char_counts, key=char_counts.get)} ({min(values)}개)") print(f"불균형 비율: {max(values) / min(values):.1f}배 차이") print() print(f"대책:") print(f" 1. 부족한 클래스에 합성 데이터 추가") print(f" 2. 가중 손실 함수 사용 (WeightedCrossEntropy)") print(f" 3. 오버샘플링/언더샘플링")
모델 학습
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import transforms
# 데이터 변환 파이프라인
train_transform = transforms.Compose([
transforms.Grayscale(),
transforms.Resize((32, 32)),
transforms.RandomRotation(5), # 약간의 회전
transforms.RandomAffine(0, translate=(0.05, 0.05)), # 약간의 이동
transforms.ToTensor(),
transforms.Normalize([0.5], [0.5]), # -1~1 범위로 정규화
])
val_transform = transforms.Compose([
transforms.Grayscale(),
transforms.Resize((32, 32)),
transforms.ToTensor(),
transforms.Normalize([0.5], [0.5]),
])
# 모델, 손실함수, 옵티마이저 설정
model = CharClassifier(num_classes=50)
criterion = nn.CrossEntropyLoss() # 다중 클래스 분류
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
scheduler = torch.optim.lr_scheduler.StepLR(
optimizer, step_size=15, gamma=0.5 # 15 에포크마다 lr 절반
)
# 학습 루프
best_val_acc = 0
for epoch in range(50):
model.train()
total_loss = 0
correct = 0
total = 0
for images, labels in train_loader:
outputs = model(images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
_, predicted = outputs.max(1)
correct += predicted.eq(labels).sum().item()
total += labels.size(0)
train_acc = 100.0 * correct / total
scheduler.step()
# 검증
model.eval()
val_correct = 0
val_total = 0
with torch.no_grad():
for images, labels in val_loader:
outputs = model(images)
_, predicted = outputs.max(1)
val_correct += predicted.eq(labels).sum().item()
val_total += labels.size(0)
val_acc = 100.0 * val_correct / val_total
print(f"Epoch {epoch+1:3d} | "
f"Loss: {total_loss/len(train_loader):.4f} | "
f"Train Acc: {train_acc:.1f}% | "
f"Val Acc: {val_acc:.1f}%")
# 최고 성능 모델 저장
if val_acc > best_val_acc:
best_val_acc = val_acc
torch.save(model.state_dict(), "best_char_classifier.pth")
print(f" -> 최고 성능 갱신! ({val_acc:.1f}%)")
추론 (문자 인식)
학습된 모델로 실제 문자를 인식하는 과정입니다.
import torch
from PIL import Image
def recognize_plate_text(model, char_images, class_names, transform):
"""분리된 문자 이미지 리스트에서 번호판 텍스트 인식"""
model.eval()
result_text = ""
confidences = []
with torch.no_grad():
for char_img in char_images:
# PIL Image로 변환 후 전처리
if not isinstance(char_img, Image.Image):
char_img = Image.fromarray(char_img)
img_tensor = transform(char_img).unsqueeze(0)
# 추론
output = model(img_tensor)
probabilities = torch.softmax(output, dim=1)
conf, predicted = probabilities.max(1)
char = class_names[predicted.item()]
result_text += char
confidences.append(conf.item())
avg_conf = sum(confidences) / len(confidences)
return result_text, avg_conf
# 사용 예시
# plate_text, confidence = recognize_plate_text(
# model, char_images, all_classes, val_transform
# )
# print(f"인식 결과: {plate_text} (평균 신뢰도: {confidence:.2%})")
성능 분석과 혼동 행렬
pythonimport numpy as np # 자주 혼동되는 문자 쌍 분석 # (실제 -> 예측 오류가 잦은 경우) confusion_examples = [ ("0", "O", "숫자 0과 영문 O"), ("1", "7", "숫자 1과 7 (기울어진 경우)"), ("가", "거", "모음 차이가 미세"), ("나", "너", "모음 차이가 미세"), ("바", "버", "모음 차이가 미세"), ("6", "8", "하단부 유사"), ] print("=== 자주 혼동되는 문자 쌍 ===") for actual, predicted, reason in confusion_examples: print(f" {actual} -> {predicted} 오인식: {reason}") print() print(f"=== 대처 방법 ===") print(f" 1. 혼동 쌍의 학습 데이터를 추가 수집") print(f" 2. 해당 문자에 특화된 증강 적용") print(f" 3. 후처리에서 번호판 형식 규칙으로 보정") print(f" 예: 한글 위치에 숫자가 오면 형식 오류로 판단")
핵심 정리
| 개념 | 설명 | 비유 |
|---|---|---|
| OCR | 이미지에서 문자를 읽는 기술 | 컴퓨터의 눈(카메라) + 뇌(CNN) |
| 문자 단위 인식 | 분리 -> 개별 분류 방식 | 글자를 하나씩 짚어 읽기 |
| CNN 분류기 | Conv -> BN -> ReLU -> Pool 반복 | 돋보기로 점점 큰 그림 보기 |
| 클래스 불균형 | 일부 문자가 많고 일부가 적음 | 시험 범위가 편중된 것 |
| 혼동 행렬 | 어떤 문자를 어떤 문자로 오인하는지 | 틀린 문제 오답 노트 |
- •OCR: 이미지에서 문자를 읽는 기술. 번호판 인식의 2단계
- •문자 단위 인식: 문자 분리 -> CNN 분류의 직관적 접근
- •CNN 분류기: 3개 Conv Block + FC Layer로 50개 클래스 분류
- •한국 번호판: 숫자 10개 + 한글 약 40개 = 약 50개 클래스
- •클래스 균형: 합성 데이터 + 가중 손실 함수로 불균형 해결
레슨 정보
- 레벨
- Level 9: 종합 프로젝트
- 예상 소요 시간
- 90분
- 참고 영상
- YouTube 링크
💡실습 환경 안내
이 레벨은 PyTorch/GPU가 필요하여 Google Colab 사용을 권장합니다.
Colab은 무료 GPU를 제공하여 PyTorch, CNN, Transformer 등을 실행할 수 있습니다.