Level 9: 종합 프로젝트
🏆

Level 9

문자 인식 모델

CNN 기반 OCR

90분
문자 인식 모델 강의 영상
강의 영상 보기 (새 탭에서 재생)YouTube

📓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 + 숫자47개
신형 일반123가 4567숫자3 + 한글1 + 숫자48개
영업용서울 12바 3456한글2 + 숫자2 + 한글1 + 숫자49개

인식 대상 클래스 정의

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) 라이브러리가 필요합니다.

python
import 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}")

클래스 균형 확인

python
import 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%})")

성능 분석과 혼동 행렬

python
import 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 반복돋보기로 점점 큰 그림 보기
클래스 불균형일부 문자가 많고 일부가 적음시험 범위가 편중된 것
혼동 행렬어떤 문자를 어떤 문자로 오인하는지틀린 문제 오답 노트
  1. OCR: 이미지에서 문자를 읽는 기술. 번호판 인식의 2단계
  2. 문자 단위 인식: 문자 분리 -> CNN 분류의 직관적 접근
  3. CNN 분류기: 3개 Conv Block + FC Layer로 50개 클래스 분류
  4. 한국 번호판: 숫자 10개 + 한글 약 40개 = 약 50개 클래스
  5. 클래스 균형: 합성 데이터 + 가중 손실 함수로 불균형 해결

레슨 정보

레벨
Level 9: 종합 프로젝트
예상 소요 시간
90분
참고 영상
YouTube 링크

💡실습 환경 안내

이 레벨은 PyTorch/GPU가 필요하여 Google Colab 사용을 권장합니다.

Colab은 무료 GPU를 제공하여 PyTorch, CNN, Transformer 등을 실행할 수 있습니다.