CLRNet을 카롱이(erp-42)에 적용한 방법을 소개하려고 한다. 내 방법을 먼저 소개하고 팀장인 지민이가 보완한 방법을 짧게 적어보았다.
해당 블로그를 참고하여 과정을 진행하였다.
(https://gaussian37.github.io/vision-concept-ipm/)
전체적인 큰 틀은 Inverse Perspective Mapping 방법을 통하여 카롱이가 인식한 차선으로부터 얼만큼 떨어져있는지 확인하는 알고리즘이다. 이를 하기 위해서는 카메라 좌표계에 대한 이론적인 배경이 필요하다.
아무래도 차선 인식 알고리즘이다보니 차선이 평행하게 똑바로 되어있는지, 굴곡이 있는 좌회전인지 우회전인지 확인하기 위해서 Bird Eye View(BEV)를 사용하였다. 추가로 지면이기 때문에 높이정보를 무시할 수 있다는 점에 있어서 BEV를 사용하였다. BEV와 더불어 Perspective mapping과 IPM에 대한 내용은 위의 블로그를 보면 설명이 자세하게 나와있다.
3D 공간상의 점에서 2D image plane으로 투영하기 위해서는 perspective projection을 해야한다. 이를 하기 위해서는 Camera의 여러가지 좌표계에 대해서 알아야 한다. 이 내용까지 다루게 되면 글이 너무 길어질 것 같아서 이는 추후에 다루도록 하겠다.
내부 파라미터는 주로 checkerboard를 이용해서 구한다. OpenCV 뿐만 아니라 다양한 open source들이 많기 때문에 쉽게 구할 수 있다. 우리는 넓은 시야각(FoV)을 위해서 어안 카메라(Fish eye camera)를 사용하였으며 내부 파라미터는 아래와 같다.
fx = 345.727618
fy = 346.002121 # focal length
cx = 320.000000
cy = 240.000000 # principal point
요즘에는 기술이 발달해서 카메라들은 왜곡계수(skew)의 값이 거의 0라고 한다. 따라서 우리는 임의로 왜곡 계수를 0으로 설정하였다. 이를 행렬로 표현하면 아래와 같다.
# Intrinsic parameter
[[345.727618 0. 320.000000]
[ 0. 346.002121 240.000000]
[ 0. 0. 1. ]]
외부 파라미터는 OpenCV의 함수인 cv2.solvepnp함수를 사용하였다. 해당 함수는 카메라의 내부 파라미터, 실제 world 좌표 그리고 이에 대응되는 이미지의 좌표를 매개변수로 갖는다. 결과로는 우리가 원하는 행렬 벡터와 이동 벡터를 Rodrigues 형식으로 반환한다.
카메라 내부 파라미터는 위에서 구했으니 World 좌표와 이에 대응되는 좌표를 구해보자.
먼저 CLRNet으로 차선을 인식한다.
해당하는 좌표에 대해서 카메라로부터 떨어진 거리를 측정해주면 된다.
(우리는 수동으로 직접 줄자로 재서 구했다... 사실 구하는 방법도 모르고 직접 재서 구하는 것이 정확하다고 생각했다.)
예를 들어서 파란 차선의 끝점들을 측정한다고 해보자.
위와 같이 왼쪽 아래의 픽셀 좌표계는 (5, 450), 왼쪽 위는 (250, 250)이라고 하자. 두 번째 그림과 같이 구한 점과 카메라 사이의 가로, 세로 거리를 각각 줄자로 재서 구하면 된다.
오른쪽 차선도 구하여 행렬로 표현하면 다음과 같다.
#2차원 영상좌표
points_2D = np.array([
(5, 450), #좌 하단
(600, 450), #우 하단
(250, 250), #좌 상단
(430, 250), #우 상단
], dtype="double")
#3차원 월드좌표, 단위는 meter
points_3D = np.array([
(3.3, 1.75, 0), #좌 하단
(3.65, -1.75, 0), #우 하단
(15.3, 1.75, 0), #좌 상단
(15.3, -1.75, 0) #우 상단
], dtype="double") # 세로, 가로, 높이
OpenCV의 SolvePnP를 사용하면 R과 t를 구할 수 있다.
retval, rvec, tvec = cv2.solvePnP(points_3D, points_2D, cameraMatrix, dist_coeffs, rvec=None, tvec=None, useExtrinsicGuess=None, flags=None)
R, _ = cv2.Rodrigues(rvec)
t = tvec
extrinsic = np.append(R, t, axis = 1) # 외부 파라미터
self.extrinsic = np.append(extrinsic, [[0,0,0,1]], axis=0)
BEV를 만들기 위해서 Real World에서 최소 몇 미터부터 최대 몇 미터까지 볼 것인지 정해줘야 한다. 또한 BEV의 1 pixel당 실제 몇 미터 떨어져서 볼 것인지 정해줘야 한다.
이 두 과정을 통해서 BEV의 전체 이미지 크기를 정할 수 있다.
# 최소, 최대 설정
self.world_x_max = 11
self.world_x_min = 0.5 # height
self.world_y_max = 4
self.world_y_min = -4 # width
# 1 pixel당 차이 정하기
self.world_x_interval = world_x_interval # height
self.world_y_interval = world_y_interval # width 작을수록 map이 커짐
# BEV의 가로 세로 구하기
self.output_width = int(np.ceil((self.world_y_max - self.world_y_min) / self.world_y_interval))
self.output_height = int(np.ceil((self.world_x_max - self.world_x_min) / self.world_x_interval))
원래 image와 BEV의 관계를 만들어줘야 하는데 이를 Look Up Table(LUT)이라고 하였다. 앞에서 구한 extrinsic과 intrinsic을 이용하여 관계를 구하면 된다.
world_x_coords = np.arange(world_x_max, world_x_min, -world_x_interval)
world_y_coords = np.arange(world_y_max, world_y_min, -world_y_interval)
output_height = len(world_x_coords)
output_width = len(world_y_coords)
map_x = np.zeros((output_height, output_width)).astype(np.float32)
map_y = np.zeros((output_height, output_width)).astype(np.float32)
for i, world_x in enumerate(world_x_coords):
for j, world_y in enumerate(world_y_coords):
# world_coord : [world_x, world_y, 0, 1]
# uv_coord : [u, v, 1]
world_coord = [world_x, world_y, 0, 1]
camera_coord = extrinsic[:3, :] @ world_coord
uv_coord = intrinsic[:3, :3] @ camera_coord
uv_coord /= uv_coord[2]
map_x[i][j] = int(np.round(uv_coord[0]))
map_y[i][j] = int(np.round(uv_coord[1]))
이렇게 구한 map_x와 map_y를 가지고 cv2.remap을 사용하면 아래와 같이 BEV image를 구할 수 있다.
output_image = cv2.remap(ori_img, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)
차선이 직진, 자회전, 우회전 중 어느것인지 알기 위해서는 차선의 곡률이 필요하다. BEV와 원본 이미지의 LUT가 잘 맞지 않은 것 같아서 이후에는 기존에 사용하던 방식을 사용했다.
우선 차선은 파란색과 초록색 이 순서대로 2개만 인식하도록 했다. 때문에 cv2.inRange함수를 통해서 해당 색상의 좌표들을 모두 구한다.
lane_blue, lane_green = [], []
dst1 = cv2.inRange(birdeyeview, (255, 0, 0), (255, 0, 0)) # 파란색 차선 검출
dst2 = cv2.inRange(birdeyeview, (0, 255, 0), (0, 255, 0)) # 초록색 차선 검출
lane_blue = np.argwhere(dst1) # 파란 차선 좌표
lane_blue = lane_blue.T
lane_green = np.argwhere(dst2) # 초록 차선 좌표
lane_green = lane_green.T
저장한 좌표들을 바탕으로 2차 함수로 모델링해서 차선의 모양을 구한다.
detect_one, detect_two = False, False
if blue.any(): # 차선 하나 검출
left_fit = np.polyfit(blue[:][0], blue[:][1], 2) #(lefty, leftx)에 대한 2차 방정식의 계수를 반환함
left_fitx = left_fit[0]*ploty**2 + left_fit[1]*ploty + left_fit[2] # 차선에 대해서 2차 함수 생성
detect_one = True # 검출 완료
if green.any(): # 차선 두개 검출
right_fit = np.polyfit(green[:][0], green[:][1], 2) #(lefty, leftx)에 대한 2차 방정식의 계수를 반환함
right_fitx = right_fit[0]*ploty**2 + right_fit[1]*ploty + right_fit[2] # 차선에 대해서 2차 함수 생성
detect_two = True # 검출 완료
예쁘게 시각화하기 위해서 그림을 그려준다.
try:
ploty = np.linspace(0, img.shape[0]-1, img.shape[0])
color_img = np.zeros_like(img) # img 사이즈에 맞게 0으로 채워짐
left = np.array([np.transpose(np.vstack([left_fit, ploty]))])
#flipud : 상하반전, vstack : 상하로 합치기, hstack : 좌우로 합치기 [[]]기준임
right = np.array([np.flipud(np.transpose(np.vstack([right_fit, ploty])))]) # 오른쪽 차선도 똑같이 진행
points = np.hstack((left, right)) # 차선끼리의 배열이 합쳐짐
cv2.fillPoly(color_img, np.int_(points), (0, 200, 255)) # 그림 그리기
add_image = cv2.addWeighted(img, 1, color_img, 0.7, 0) # img와 inv_perspective 사진을 하나로 합치기
except: # 그리지 못하는 경우 -> left나 right 둘 중 하나가 0
print("Draw Failed!")
cv2.putText(color_img, "draw_failed", org=(90, 200),fontFace=cv2.FONT_HERSHEY_COMPLEX,
fontScale=0.6, color=(255 ,255, 255), thickness=1, lineType=cv2.LINE_AA, bottomLeftOrigin=False)
add_image = cv2.addWeighted(img, 1, color_img, 0.7, 0)
위와 같이 색이 채워진 것을 볼 수 있다.
마지막으로 중앙으로부터 차선이 얼만큼 떨어져 있는지 계산하는 코드를 작성해주면 된다. 원래는 곡률을 구해서 곡률로 steer를 계산하려고 했는데 erp-42에 사용되는 steer값의 기준을 아무도 몰라서 차선의 중앙을 추종하는 단순한 방식을 사용하였다.
# 대강적인 steer값을 구하는 함수
center_length = 0 # 차량이 차선 중심으로부터 얼만큼 떨어져있는지
car_pos = img.shape[1]/2 # 차량은 이미지의 중앙값
if detect_two: # 양쪽 다 구해진 경우
lane_center_position = (leftx[len(leftx)//2] + rightx[len(rightx)//2]) / 2 # 차선의 중앙 픽셀값
center_length = (lane_center_position - car_pos) * xm_per_pix * 100 # 중앙에서부터 떨어진 거리(cm단위)
print("Detect both!")
elif detect_one: # 왼쪽만 구해진 경우
sub_first_last = leftx[0] - leftx[-1]
if sub_first_last > 0: # 양수면 오른쪽으로
direction = 1
else: # 음수면 왼쪽으로
direction = -1
center_length = (leftx[len(leftx)//2] - car_pos) * xm_per_pix * 100 # 중앙에서부터 떨어진 거리(cm단위)
center_length = abs(center_length) * direction
print("Only one!")
else:
print("Detect False!")
차선이 한쪽만 구해진 경우는 어떻게 할지 고민을 많이 했었다. 대부분의 경우에는 우회전이나 좌회전 할 때 한쪽만 검출된다. 특히 우회전 할때는 왼쪽 차선만 검출이 되는데 이를 중점적으로 고려하여 코드를 작성하였다.
이와 같은 차선이 있고 카메라와 딥러닝의 한계로 인해서 오른쪽 차선이 검출이 안된다고 해보자.
픽셀 좌표로 봤을때 leftx[0]은 제일 위에 있는 x좌표가 저장되고, leftx[-1]은 제일 아래에 있는 x좌표가 저장된다. leftx[0]에서 leftx[-1]을 빼면 양수가 되고 이는 우회전을 해야함을 의미한다. 따라서 direction 변수에 1을 저장한다. 이렇게 하는 이유는 erp-42가 양수값일때 우회전을 하고 음수값일때 좌회전을 하기 때문이다.
사실 이렇게 하게 되면 아래와같은 반대의 경우에 문제가 생길 수 있다.
위와 같은 코드를 사용하면 오른쪽 차선을 왼쪽 차선으로 인식하고 좌회전을 하기 때문이다. 하지만 좌회전을 하는 경우 왼쪽 차선을 정상적으로 인식하여 다시 정상 차선으로 돌아오는 것을 실험적으로 확인하였다.
이렇게 해서 차선 알고리즘을 새롭게 개발하였고 직진 차선에서는 인식이 잘 되는 것을 확인할 수 있었다.
velog는 동영상 첨부가 안되는 것을 처음 알았다. 때문에 아래의 원드라이브 링크를 통해 동영상을 볼 수 있다. 링크를 클릭하고 왼쪽 상단의 다운로드 버튼을 누르면 동영상을 다운 받아 볼 수 있다.
https://1drv.ms/v/s!AvdDTPBzW74BgYwIc8BTSkA8jJz7iw?e=fbjFrf
해당 영상을 통해 직진 차선에서 인식이 잘 되는 것을 볼 수 있다.
https://1drv.ms/v/s!AvdDTPBzW74BgYwJ7TQ0FOfs2BH-rQ?e=RdXoYj
해당 영상은 인식 실패 영상이다. 횡단보도와 자회전 같은 경우에는 차선이 없기 때문에 인식을 못하는 것을 볼 수 있다. 뿐만 아니라 차선이 아닌 노이즈가 인식이 되는 경우도 있었다.
팀장인 지민이가 많이 도와줬다. 먼저 Tusimple dataset 뿐만 아니라 우리나라 도로의 상황에 맞게 AIhub와 ETRI dataset을 이용해서 도합 4만장의 dataset 학습을 진행해주었다.
차선 인식 알고리즘도 개선해주었다. CLRNet 같은 경우에는 이전 이미지를 고려하여 현재 이미지를 처리하는 것이 아닌 그때 그때마다 이미지를 처리하는 식으로 진행된다. 즉 이전 차선의 위치가 반영이 되지 않는다는 것이다.
때문에 앞서 2개만 인식하는 나의 방법에는 한계가 있었다. 지민이는 여러 개의 차선을 인식하도록 한 후에 중앙에서부터 가장 가까운 순서대로 왼쪽 오른쪽 차선을 인식하였다. 인식이 안되는 경우를 고려하여 이동 평균 필터와 비슷한 방식으로 차선을 저장하였다.
덕분에 많은 성능 효과를 보았지만.,.... 이는 딥러닝측에서 인식이 되지 않으면 말짱 도루묵인 것이다. 애초에 차선을 인식하지 못 한다면 그 이후의 과정은 아무 쓸모가 없는 것이다.
앞서 얘기한 좌회전, 우회전 문제, 횡단 보도 문제, 차선이 없는 곳의 문제 등등의 이유로 결과적으로 대회에서는 사용하지 않았다. 애초에 차선은 메인이 아니라 gps가 안 됐을때 서브로 사용하려고 만든 것이다.
우리는 gps가 안 되는 경우가 어떤지 어떻게 알 것이며, 어떻게 사용할 것이며 등등을 고려하다 시간적인 이유로 결국 마무리하였다. 개인적으로는 사용하지 못해서 아쉽지만 그래도 덕분에 많이 배울 수 있었고 실력적으로 많이 성장하게 되는 계기가 되었다!
우리의 차선이 추후 마카롱 후배들이 사용해줬으면 좋겠다..!
또한 블로그를 너무 늦게 작성하다보니 사진들이 별로 없어서 아쉬웠다. 카톡과 디스코드 사진들이 만료기간이 지났다. 애초에 별로 안 찍어둔 것도 있다. 그때는 개발하기 급급해서 사진찍고 기록하고 할 생각을 못 했다. 제일 아쉬운 것은 rviz로 나오는 차선을 저장하지 못하고 글로만 적은 것이 아쉽다.
뭔가 기록해뒀었으면 더 좋은 양질의 게시글이 됐었을텐데 하는 아쉬움과 앞으로 무언가를 할 때는 기록을 많이 해야겠다는 생각이 들었다.
그래도 재밌었던 마카롱의 1년! 좋은 사람들과 좋은 경험들을 할 수 있어서 좋았다!