Featured image of post [AD -SHARP2] 자율주행 차선 추출 알고리즘 개발

[AD #2] 자율주행 차선 추출 알고리즘 개발

HSV Color Extraction & Bird-Eye View

Introduction

이번 포스팅에서는 카메라 기반으로 차선을 추출하는 방법을 소개한다. 사실 해당 공모전에서는 차선이라고 하기엔 민망하지만, 하나의 선을 따라가는 규칙 때문에 실제 자율주행과는 거리가 멀다. 일반적으로 차선 추출 알고리즘으로 Canny Edge Detection 을 많이 사용하는데 필자는 python 기반의 해당 알고리즘의 처리시간이 요구시간 보다 오래 걸려 해당 방법을 사용하지 않는 방법을 개발했다 (사실 이 때문에 대회 본선에서 원하는 결과를 달성하지 못한 것 같다). 따라서 해당 글의 아이디어는 참고만 하길 바란다.


Track & Rule

경기장 트랙 예시

  • 경기장: 검은 바탕, 폭 2cm 노란색 라인
  • 초기 차량 위치: 장애물A 전방
  • 장애물A 제거 후, 차량 자동 출발
  • 차량 맨 앞이 경기장 경계 진입 시부터 시간 계측 시작
  • 장애물B 앞에서 차량 자동 정지
  • 차량 정지 즉시 장애물B 제거 후, 차량 자동 출발
  • 차량 맨 앞이 경기장 경계 도달 시까지 시간 계측 종료

위 영상은 실제 연습 경기장의 테스트 주행이다. 영상에서 사용한 장애물은 우리가 임의로 제작한 장애물이고 실제 장애물은 아래와 같이 나무로 된 넓은 판 형태였다. 장애물 인식에 대한 내용은 다른 포스팅에서 다루도록 하겠다.

장애물

추가로 실제 본 경기에서 순위를 매기는 방식은 다음과 같다.

  1. 라인을 벗어나거나 장애물과 부딪혀 사람이 개입하게 되면 개입 횟수 증가
  2. 개입 횟수에 따른 그룹으로 나눔
  3. 같은 개입 횟수 그룹에서 완주 시간이 빠른 순서대로 순위를 매김

Line Extraction Algorithm

Demo Track

비전 알고리즘을 먼저 개발하기 위해 검은 도화지와 노란색 절연테이프를 구입하여 경기장 트랙을 직접 제작하여 영상을 촬영하였다.

완성된 트랙은 실제 차에 카메라가 장착되어 있다고 생각하고 영상을 촬영해야 했다. 그래서 PiCar에 핸드폰을 테이프로 덕지덕지 붙여 고정시킨 후 동영상을 촬영하였다.

자동차를 원격으로 조정하는 방법도 있었지만, 중심을 맞춰서 조정하기가 쉽지 않다고 판단하여 직접 손으로 차를 밀면서 영상을 촬영했다. (좀 더 좋은 영상을 얻기 위해 무릎에 멍들 정도로 많이 촬영했다…)

아래 영상은 해당 방법으로 촬영한 영상이다. 직선이라 하여도 종이 재질로 인해 굴곡이 있다. 검은 바탕으로 모두 채워야 하겠지만 예산 부족으로 인해 노란 선과 비슷한 색상의 바닥을 모두 가릴 수 없었다.


HSV Color Extraction

RGB 사진에서 노란 선을 추출하기 위해 색 추출에 효과적인 HSV 색상 공간으로 변환한다. 그리고 노란색의 HSV 범위를 구해야 하는데, 해당 범위는 조도, 카메라, 이미지 특성에 따라 값이 항상 일정하지 않기 때문에 본인이 사용하는 이미지에서 직접 구해야한다.

필자는 OpenCV inRange 함수를 이용 하였다. 범위를 추측하여 대입하여 색상 범위를 구하는 것은 일반 적으로 쉽지 않다. 그래서 GUI 도구로 실시간으로 동영상의 색상 범위를 추출 할 수 있도록 개발하였다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import cv2
import numpy as np
import copy

def onChange(pos):
    pass

cv2.namedWindow('dst')
cv2.createTrackbar('Hmin', 'dst', 0, 180, onChange)
cv2.createTrackbar('Hmax', 'dst', 0, 180, lambda x : x)

cv2.createTrackbar('Smin', 'dst', 0, 255, onChange)
cv2.createTrackbar('Smax', 'dst', 0, 255, lambda x : x)

cv2.createTrackbar('Vmin', 'dst', 0, 255, onChange)
cv2.createTrackbar('Vmax', 'dst', 0, 255, lambda x : x)

cv2.setTrackbarPos('Hmin', 'dst', 0)
cv2.setTrackbarPos('Hmax', 'dst', 180)
cv2.setTrackbarPos('Smin', 'dst', 0)
cv2.setTrackbarPos('Smax', 'dst', 120)
cv2.setTrackbarPos('Vmin', 'dst', 0)
cv2.setTrackbarPos('Vmax', 'dst', 150)

camera = cv2.VideoCapture('video_path.mp4')

while True:
    if not stop:
        _, raw = camera.read()
        image = cv2.cvtColor(raw, cv2.COLOR_BGR2GRAY)

    key = cv2.waitKey(1)
    if key == ord('s'):
        stop = not stop
    elif key == ord('q'):
        break

    hmin = cv2.getTrackbarPos('Hmin', 'dst')
    hmax = cv2.getTrackbarPos('Hmax', 'dst')
    smin = cv2.getTrackbarPos('Smix', 'dst')
    smax = cv2.getTrackbarPos('Smax', 'dst')
    vmin = cv2.getTrackbarPos('Vmix', 'dst')
    vmax = cv2.getTrackbarPos('Vmax', 'dst')
    dst = cv2.inRange(image, (hmin, smin, vmin), (hmax, smax, vmax))
    
    dst = cv2.cvtColor(dst, cv2.COLOR_GRAY2BGR)
    dst = np.hstack((raw, dst))
    cv2.imshow('dst', dst)

cv2.destroyAllWindows()

이전에 촬영한 영상에 대해 해당 코드를 실행하면 아래와 같은 창이 실행 된다. 해당 프로그램을 종료 하려면 q를 입력하고 일시중지 하려면 s를 입력하면 된다.

상단의 트랙 바를 값을 조정하면 아래와 같이 추출된 영역이 변경된 것을 확인 할 수 있다.

직접 값을 변경하고 실행하는 것보다 덜 번거롭기는 하지만, 사실 이 방법으로 찾기에도 쉽지 않다.

🚨 빛이 어두우면 노이즈가 발생하여 색 추출이 깔끔하지 않고, 빛이 너무 밝으면 빛 반사로 인하여 실제 색상보다 하얗게 나온다.

💡 노란색의 경우 검은색 보다 빛을 더 많이 반사하기 때문에 차선 검출을 위해서 노란색을 추출하는 것이 아닌 빛 반사가 좀 더 약한 검은색 바탕을 추출하여 노란색 선을 추출하는 방법 또한 존재한다. 이후 필자는 검은색 배경을 추출하는 방식을 이용하였다.


Bird-Eye View

앞선 방법으로 차선을 추출 하였다면 해당 차선을 하늘에서 지면을 평행하게 바라보는 Bird-Eye View 형태로 변경할 것이다. Bird-Eye View로 변경하는 이유는 Vanishing Point로 인해 시점에 따라 평행한 선이 한점으로 모이게 되어 부정확한 정보를 얻거나 Vanishing Line 위쪽 정보는 불필요하기 때문에 연산량을 줄여주기 위함이다.

Bird-Eye View로 변환하는 방법은 변환하고자 하는 영상의 네개의 점과 Bird-Eye View로 보았을 때 동일한 네개의 점을 1대1 매칭하여 Perspective Warp 를 적용하면 된다. 앞서 언급한 네개의 점을 직접 사진을 보면서 점을 찍을 수 있지만, 부정확하고 번거롭기 때문에 OpenCV의 함수와 Checkerboard를 이용하여 자동으로 Trasnform Matrix를 계산하는 프로그램을 작성하였다.

Camera Calibration

카메라 Intrinsic 특징으로 인해 왜곡이 발생할 수 있기 때문에 Bird-Eye View 이미지로의 Transform Matrix를 정밀하게 구하기 위해서 Camera Calibration Matrix를 먼저 계산 해야한다. 우리는 checkerboard를 다양한 위치와 각도에서 촬영한 15개의 이미지로 calibration을 수행하였다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import numpy as np
import cv2
import glob
import pickle
from os import path

def calibrate_camera(nx, ny, basepath):
    """
    :param nx: number of grids in x axis
    :param ny: number of grids in y axis
    :param basepath: path contains the calibration images
    :return: write calibration file into basepath as calibration_pickle.pickle
    """

    objp = np.zeros((nx*ny,3), np.float32)
    objp[:,:2] = np.mgrid[0:nx,0:ny].T.reshape(-1,2)

    # Arrays to store object points and image points from all the images.
    objpoints = [] # 3d points in real world space
    imgpoints = [] # 2d points in image plane.

    # Make a list of calibration images
    images = glob.glob(path.join(basepath, '*.png'))

    # Step through the list and search for chessboard corners
    for fname in images:
        img = cv2.imread(fname)
        gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)

        # Find the chessboard corners
        ret, corners = cv2.findChessboardCorners(gray, (nx,ny),None)

        # If found, add object points, image points
        if ret == True:
            objpoints.append(objp)
            imgpoints.append(corners)

            # Draw and display the corners
            img = cv2.drawChessboardCorners(img, (nx,ny), corners, ret)
            cv2.imshow('input image',img)
            cv2.waitKey(500)

    cv2.destroyAllWindows()

    # calibrate the camera
    img_size = (img.shape[1], img.shape[0])
    ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(objpoints, imgpoints, img_size, None, None)

    # Save the camera calibration result for later use (we don't use rvecs / tvecs)
    dist_pickle = {}
    dist_pickle["mtx"] = mtx
    dist_pickle["dist"] = dist
    destination = path.join(basepath,'calibration_pickle.pickle')
    pickle.dump( dist_pickle, open( destination, "wb" ) )
    print("calibration data is written into: {}".format(destination))

    return mtx, dist

if __name__ == "__main__":

    nx, ny = 4, 7  # number of grids along x and y axis in the chessboard pattern
    basepath = './image_path'  # path contain the calibration images

    # calibrate the camera and save the calibration data
    calibrate_camera(nx, ny, basepath)

nx,ny에 checkboard 칸의 개수를 넣고 basepath에 이미지들을 저장한 경로를 넣으면 다음과 같은 실행 결과와 해당 경로에 calibration matrix가 저장된 calibration_pickle.pickle 파일을 확인 할 수 있다.

calibration을 적용하고 원본 이미지와 비교하기 위해 다음 코드를 사용할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import numpy as np
import cv2
import glob
import pickle
import matplotlib.pyplot as plt
from os import path

def undistort_image(imagepath, calib_file, visulization_flag):
    """ undistort the image and visualization

    :param imagepath: image path
    :param calib_file: includes calibration matrix and distortion coefficients
    :param visulization_flag: flag to plot the image
    :return: none
    """
    mtx, dist = load_calibration(calib_file)
    print('mtx', mtx)
    print('dist', dist)
    img = cv2.imread(imagepath)

    # undistort the image
    img_undist = cv2.undistort(img, mtx, dist, None, mtx)
    img_undistRGB = cv2.cvtColor(img_undist, cv2.COLOR_BGR2RGB)

    if visulization_flag:
        imgRGB = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        f, (ax1, ax2) = plt.subplots(1, 2)
        ax1.imshow(imgRGB)
        ax1.set_title('Original Image', fontsize=30)
        ax1.axis('off')
        ax2.imshow(img_undistRGB)
        ax2.set_title('Undistorted Image', fontsize=30)
        ax2.axis('off')
        plt.show()

    return img_undistRGB

def load_calibration(calib_file):
    """
    :param calib_file:
    :return: mtx and dist
    """
    with open(calib_file, 'rb') as file:
        # print('load calibration data')
        data= pickle.load(file)
        mtx = data['mtx']       # calibration matrix
        dist = data['dist']     # distortion coefficients

    return mtx, dist

image_name = './origin_image_path'
# loading
img = cv2.imread(image_name)
calib_file = './calibration_pickle.pickle'

mtx, dist = load_calibration(calib_file)

# saving 2 images for comparison
img_undist = cv2.undistort(img, mtx, dist, None, mtx)
img = np.vstack((img,img_undist))
cv2.imwrite('./calibresult.jpg'.format(i), img) #undistorted

아래는 위 코드를 이용하여 15개의 이미지 중 하나를 실행한 결과이다.

위쪽은 원본 이미지이고, 아래쪽이 왜곡이 제거된 이미지이다. checkerboard를 확인해보면 좌측에 굽어있던 부분이 직선으로 펴진것을 확인할 수 있다.


Perspective Warp

Perspective Warp을 하기에 실제 카메라 위치에서 찍은 사진과 Bird-Eye View로 찍은 사진 두개가 필요하다. 두 개의 사진을 앞서 개발한 calibration 코드를 통해 왜곡을 제거한다.

     

이후 아래의 코드를 이용하여 두 이미지의 checkboard의 네 귀퉁이를 자동으로 추출하고 해당 좌표를 변환하는 transform matrix를 계산한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import cv2
import numpy as np

def corner(path, nx, ny):
    # Step through the list and search for chessboard corners
    objp = np.zeros((nx*ny,3), np.float32)
    objp[:,:2] = np.mgrid[0:nx,0:ny].T.reshape(-1,2)

    raw_img = cv2.imread(path)
    gray = cv2.cvtColor(raw_img,cv2.COLOR_BGR2GRAY)

    # Find the chessboard corners
    ret, corners = cv2.findChessboardCorners(gray, (nx,ny), None)
    draw_img = cv2.drawChessboardCorners(raw_img, (nx,ny), corners, ret)

    cv2.imshow('', draw_img)
    cv2.waitKey(0)

    corners = [ ((int)(corners[idx][0][0]), (int)(corners[idx][0][1])) for idx in [0, nx-1, -nx, -1] ]

    return corners

nx, ny = 4, 7
origin_corners = corner('./origin_checkboard.png', nx, ny)
bird_eye_corners = corner('./bird_eye_view_checkboard.png', nx, ny)

raw_img = cv2.imread('./origin_checkboard.png')
origin_view_point = np.float32([ list(corner) for corner in origin_corners])
bird_eye_view_point = np.float32([ list(corner) for corner in bird_eye_corners])

origin2bird_transform_matrix = cv2.getPerspectiveTransform(origin_view_point, bird_eye_view_point)
warp_img = cv2.warpPerspective(raw_img, origin2bird_transform_matrix, (raw_img.shape[1], raw_img.shape[0]))
cv2.imshow("", warp_img)
cv2.waitKey(0)

먼저 아래와 같이 각 이미지의 코너를 먼저 검출하고 네 귀퉁이를 좌표를 추출한다.

     

그리고 기존 origin 이미지에 OpenCV의 함수인 getPerspectiveTransform를 통해 transform matrix를 계산하고 warpPerspective 함수로 적용해보면 다음과 같이 Bird-Eye View 이미지를 얻을 수 있다.

결과를 확인해보면 기존 Bird-Eye View로 촬영한 이미지와 동일한 위치에 checkboard가 위치한 것을 확인 할 수 있다. 기존 이미지 시야에서 벗어난 부분(좌/우측 하단) 이미지 정보는 없기 때문에 검은색으로 나타나게 되고, 이미지의 하단에서 상단으로 갈 수록 해상도가 흐릿해지는 것을 확인할 수 있다.

이는 기존 이미지에서 보았을 때 거리가 멀수록 해당 부분의 픽셀 정보는 상대적으로 적어지게 된다. 그래서 interpolation을 통해 인근 픽셀 정보로 채워 상대적으로 흐릿해 보인다. 하지만 이정도 정보만으로 충분히 차선 추출을 할 수 있다.


🦉 마무리 🦉

이번 포스팅에서는 HSV 색상 공간을 이용하여 특정 색 추출을 할 수 있는 프로그램과 카메라 내부 특성으로 인한 왜곡을 제거하는 calibration, 이미지를 Bird-Eye View 형태로 자동으로 변환하는 코드를 개발하였다. 위에 작성한 내용을 토대로 Bird-Eye View에서 HSV Color Extraction을 통해 차선을 검출 할 수 있겠지만, 불필요한 연산이 많아 처리속도가 낮다. 그래서 다음 포스팅에서 Sliding Window 방법을 이용하여 Bird-Eye View에서 효과적인 차선 추출 방법을 소개하도록 하겠다.

💡 필자는 640 x 360 크기의 이미지를 이용하여 Bird-Eye View로의 변환 처리 시간을 측정해 보았는데, warpPerspective 함수를 통해 Bird-Eye View로 변환하는 시간이 10ms 정도 소요되었다. 이는 실시간 처리에서 10 fps 정도의 처리를 하기 때문에 카메라의 60 fps를 처리하기 위해서는 연산시간을 더 줄여야 했다. warpPerspective 없이 연산하는 방법은 이후 최적화 포스팅에서 다루도록 하겠다.

Built with Hugo
Theme Stack designed by Jimmy