[OpenCV] 차량 영상에서의 번호판 식별 알고리즘
이번에는 가장 널리 쓰이는 Image Processing 라이브러리인 OpenCV(Open Source Computer Vision)를 이용하여
차량 영상에서 번호판 영역을 특정하고 OCR을 이용하여 인식까지 진행해보도록한다.
우선 데이터셋은, 학과에서 수강한 영상처리 과목에서 제공해준 차량 번호판 데이터셋을 활용했다.
전체적인 프로세스는 다음과 같다.
1. 컬러 영상으로 들어온 입력 영상을 GrayScale 영상으로 변환
2. 노이즈 제거를 위한 Gaussian Blurring과 Morphology 연산 수행
3. 균일하지 않은 명암을 가진 영상을 처리하기 위한 Adaptive Thresholding(적응형 이진화) 수행
4. 전처리된 영상에 대한 Edge 검출 및 Contour 그리기
5. 그린 Contour 중 번호판 글자로 추정되는 객체만 분류
6. 분류한 객체 중, 객체 간의 거리가 일정한 간격으로 연속되었다면 번호판으로 판단
7. 번호판 영역으로 판단된 Contour 중 가장 왼쪽 컨투어의 좌상단 좌표와 가장 오른쪽 컨투어의 우하단 좌표 추출
8. 해당 좌표에 Rectangle 그리기
9. 해당 영역을 Crop하고 OCR 수행
먼저 영상에 대한 일반적인 전처리를 수행해본다.
def detect(img):
height, width = img.shape
channel = 1
# Morphology Operation
StructuringElement = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
topHat = cv2.morphologyEx(img, cv2.MORPH_TOPHAT, StructuringElement)
blackHat = cv2.morphologyEx(img, cv2.MORPH_BLACKHAT, StructuringElement)
img_topHat = cv2.add(img, topHat)
img = cv2.subtract(img_topHat, blackHat)
# Gaussian Blurring
blur = cv2.GaussianBlur(img, (5, 5), 2)
# Adaptive Thresholding
threshold = cv2.adaptiveThreshold(blur, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY,
91, 3)
우선 main 함수에서 detect 함수로 GrayScale로 변환한 영상을 전달한다.
전달받은 영상의 높이와 폭을 shape 함수를 이용해 저장해두고
tophat과 blackhat을 이용한 Morphology 연산을 수행하기 전, 정사각형 모양을 갖는 커널을 생성했다.
해당 커널을 이용해 Morphology 연산을 수행한 뒤, 임의의 값으로 Gaussian Blurring을 수행했으며
입력 영상들이 대체적으로 균일하지 않은 명암을 가지고 있기 때문에, 일반적인 영상 임계화가 아닌
적응형 임계화를 수행하여 전처리가 잘 될 수 있도록 했다.
입력 영상과 전처리 후의 영상은 다음과 같다.
여기서 설정한 값들은 내가 가진 데이터셋에 최적화한 값이기 때문에 상황에 따라 값은 달라질 수 있다.
다음으로 번호판 영역을 추정하고 Contour를 그리는 과정이다.
# Find and Drawing Contours
contours, _ = cv2.findContours(threshold, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
contour_result = np.zeros((height, width, channel), dtype=np.uint8)
cv2.drawContours(contour_result, contours, -1, (255, 255, 255))
contour_result = np.zeros((height, width, channel), dtype=np.uint8)
# Drawing Rectangle with Contours
cntDict = []
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
cv2.rectangle(contour_result, (x, y), (x + w, y + h), (255, 255, 255), 2)
cntDict.append({'contour': contour, 'x': x, 'y': y, 'w': w, 'h': h, 'cx': x + (w / 2), 'cy': y + (h /
2)})
위에서 구한 이진화 영상에 대해 Contour들을 찾고 그 좌표값들을 Dictionary에 저장해준다.
다음으로 번호판 영역을 추정하는 과정이다.
거의 대부분의 Edge에 Contour가 그려져 있기 때문에, 쓸모없는 Contour들을 걸러낼 필요가 있다.
그래서 최소한의 값을 지정하여 번호판 글자와 거리가 먼 Contour들을 걸러내는 과정을 거친다.
# Plate Size Assumption
MINAREA = 100 # Minimum Area
MINWIDTH = 4 # Minimum Width
MINHEIGHT = 2 # Minimum Height
MINRATIO = 0.25 # Minimum Ratio (Width / Height)
MAXRATIO = 0.9 # Maximum Ratio (Width / Height)
# Candidates of the Plate
cntPossible = []
cnt = 0
for item in cntDict:
# Calculate Conditions
area = item['w'] * item['h'] # 가로 * 세로 넓이
ratio = item['w'] / item['h'] # 가로 / 세로 비율
# Check Conditions
if area > MINAREA and item['w'] > MINWIDTH and item['h'] > MINHEIGHT and MINRATIO < ratio
< MAXRATIO:
item['index'] = cnt
cnt += 1
cntPossible.append(item)
contour_result = np.zeros((height, width, channel), dtype=np.uint8)
# Draw Rectangle on Candidates
for cnt in cntPossible:
cv2.rectangle(contour_result, (cnt['x'], cnt['y']), (cnt['x'] + cnt['w'], cnt['y'] + cnt['h']), (255, 255,
255), 2)
구현한 코드는 다음과 같다.
임의로 위 조건들을 Fix 하고, 그린 Contour들이 해당 조건들을 만족하는지 검사한다.
만약 조건에 부합한다면 해당 Contour들을 cntPossible 변수에 저장한다.
그럼 다음과 같은 결과를 얻을 수 있다.
이제 번호판 영역을 특정해야하는데, 여기서 번호판의 특성을 이용한다.
번호판은 다음과 같은 특성들이 있다.
- 아주 유사한 비율의 경계 영역이 일정 개수 이상 일정한 간격으로 정렬되어 있음.
- 90도 이하의 각도에서 수평으로 정렬되어 있음.
- 각 숫자 영역의 가로 / 세로 비율은 거의 동일함.
위 조건에 맞는 후보 영역들을 선별하면 번호판 영역을 특정할 수 있다.
# Numeral Arrange Appearance Conditions
MAXDIAG = 5 # Average Distance from Center
MAXANGLE = 12 # Maximum Angle between Contours
MAXAREADIFF = 0.5 # Maximum Area Difference between Contours
MAXWIDTHDIFF = 0.8 # Maximum Width Difference between Contours
MAXHEIGHTDIFF = 0.2 # Maximum Height Difference between Contours
MINCHARACTER = 5 # Minimum Counts of Numbers
# Recursive Function to Find Characters
def FindCharacter(cntList):
result = []
for item1 in cntList:
match = []
for item2 in cntList:
# Same Index Contours are not be compared
if item1['index'] == item2['index']:
continue
# Distance from Center Point
dx = abs(item1['cx'] - item2['cx'])
dy = abs(item1['cy'] - item2['cy'])
# First Contour's Diagonal Length
diagLength = np.sqrt(item1['w'] ** 2 + item1['h'] ** 2)
# Distance between Vectors
distance = np.linalg.norm(np.array([item1['cx'], item1['cy']]) - np.array([item2['cx'], item2['cy']]))
# Angle Calculations
if dx == 0:
angle = 90
else:
angle = np.degrees(np.arctan(dy / dx))
DiffArea = abs(item1['w'] * item1['h'] - item2['w'] * item2['h']) / (item1['w'] * item1['h'])
DiffWidth = abs(item1['w'] - item2['w']) / item1['w']
DiffHeight = abs(item1['h'] - item2['h']) / item1['h']
# Check Conditions
if distance < diagLength * MAXDIAG and angle < MAXANGLE and DiffArea < MAXAREADIFF and DiffWidth < MAXWIDTHDIFF and DiffHeight < MAXHEIGHTDIFF:
match.append(item2['index'])
match.append(item1['index'])
if len(match) < MINCHARACTER:
continue
result.append(match)
# Unmatched Contours
unmatch = []
for item3 in cntList:
if item3['index'] not in match:
unmatch.append(item3['index'])
# Only Take Same Contours
unmatch = np.take(cntPossible, unmatch)
# Function Called Recursively
recursion = FindCharacter(unmatch)
for i in recursion:
result.append(i)
break
return result
# Index of Character Contours
indexResult = FindCharacter(cntPossible)
matchResult = []
for indexList in indexResult:
matchResult.append(np.take(cntPossible, indexList))
# Visualize Possible Contours
possibleResult = np.zeros((height, width, channel), dtype=np.uint8)
for items in matchResult:
for item in items:
cv2.rectangle(possibleResult, (item['x'], item['y']), (item['x'] + item['w'], item['y'] + item['h']), (255, 255, 255), 2)
# Get Number Plate Informations
for i, items in enumerate(matchResult):
# Sortion to Axis X
sortion = sorted(items, key=lambda x: x['cx'])
return sortion
이 코드에서는 앞서 검출한 Contour 객체들에 이중 반복문 Index를 이용해 접근하여 두 Contour간의
거리, 각도, 넓이, 높이, 폭, 중심으로부터의 거리 등을 번호판의 특성을 토대로 검사하여
번호판에 해당하는 Contour들을 분류해낸다.
분류해낸 Contour들을 제외하고 남은 영역에도 번호판 숫자에 해당하는 Contour가 존재할 수 있기 때문에,
재귀적으로 호출하여 모든 조건에 알맞는 컨투어들을 식별하도록 했다.
그리고 마지막엔 X축을 기준으로 좌표값들을 순서대로 정렬하여 번호판 영역에 해당하는 Contour들의 좌표값을 반환하도록 했다.
그럼 다음과 같이 번호판 영역을 추정한 영상을 얻을 수 있다.
앞서 본 것과 같이 추정한 영상에서 얻은 좌표를 원본 영상에서 Crop하면
오른쪽 하단으로 약간 기울어진 번호판 영상이 나온다.
이렇게 되면 추후 OCR을 적용할때 인식 정확성에 영향을 줄 수 있기 때문에
다음과 같은 각도 계산 공식을 적용하고, OpenCV에서 제공하는 warpPerspective 함수를 사용하여
수평을 맞추어 출력해주는 과정을 최종적으로 거친다.
마지막으로, 번호판 영역에 Rectangle을 그린 영상과 번호판 검출 영상에 OCR을 수행한 결과를 함께 출력하면
다음과 같은 영상을 얻을 수 있다. (하단에 OCR 결과 문자열)
OCR은 Conda 환경에서 Easy OCR을 세팅하여 사용했다.