[OpenCV]차선 인식

사용환경 : Ubuntu 18.04 LTS, Python 3.7.7
OpenCV Version = 4.2.0.34  // 3.x 버전을 사용해도 O

유투브 영상:

https://www.youtube.com/watch?v=ipyzW38sHg0

 

결과 영상:

 

1. Youtube 영상 불러오기

import youtube_dl
import pafy

url = 'https://www.youtube.com/watch?v=ipyzW38sHg0'
video = pafy.new(url)
best = video.getbest(preftype = 'mp4')

cap = cv2.VideoCapture(best.url)

frame_size = (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))

while True:
	retval, img = cap.read()
    if not retval:
    	break
    cv2.imshow("video", img)
    key = cv2.waitKey(25)
    if key == 27:
    	break

if cap.isOpened():
	cap.release()
cv2.destroyAllWindows()
    

※ youtube_dl과 pafy 설치

$ pip install youtube_dl && pip install pafy

 

2. Warpping (Bird Eye View)

def wrapping(image):
    (h, w) = (image.shape[0], image.shape[1])

    source = np.float32([[w // 2 - 30, h * 0.53], [w // 2 + 60, h * 0.53], [w * 0.3, h], [w, h]])
    destination = np.float32([[0, 0], [w-350, 0], [400, h], [w-150, h]])

    transform_matrix = cv2.getPerspectiveTransform(source, destination)
    minv = cv2.getPerspectiveTransform(destination, source)
    _image = cv2.warpPerspective(image, transform_matrix, (w, h))

    return _image, minv

※ getPerspectiveTransform(원근법) :

Perspective(원근법) 변환은 직선의 성질만 유지가 되고, 선의 평행성은 유지가 되지 않는 변환입니다. 기차길은 서로 평행하지만 원근변환을 거치면 평행성은 유지 되지 못하고 하나의 점에서 만나는 것 처럼 보입니다.(반대의 변환도 가능)

4개의 Point의 Input값과 이동할 output Point 가 필요합니다.

변환 행렬을 구하기 위해서는 cv2.getPerspectiveTransform() 함수가 필요하며, cv2.warpPerspective() 함수에 변환행렬값을 적용하여 최종 결과 이미지를 얻을 수 있습니다.

참고 블로그 : opencv-python.readthedocs.io/en/latest/doc/10.imageTransformation/imageTransformation.html

 

이미지의 기하학적 변형 — gramman 0.1 documentation

변환이란 수학적으로 표현하면 아래와 같습니다. 예로는 사이즈 변경(Scaling), 위치변경(Translation), 회전(Rotaion) 등이 있습니다. 변환의 종류에는 몇가지 분류가 있습니다. Scaling Scaling은 이미지의

opencv-python.readthedocs.io

minv값은 마지막에 warpping된 이미지를 다시 원근감을 주기 위해 반대의 matrix값을 저장하는 변수입니다.

결과 이미지 :

아래 사진에서 빨간 원은 source 점을, 파란 원은 destination 점을 나타냅니다.

source                                                                                                                              destination

 

3. Color Filter (HLS 사용)

def color_filter(image):
    hls = cv2.cvtColor(image, cv2.COLOR_BGR2HLS)

    lower = np.array([20, 150, 20])
    upper = np.array([255, 255, 255])

    yellow_lower = np.array([0, 85, 81])
    yellow_upper = np.array([190, 255, 255])

    yellow_mask = cv2.inRange(hls, yellow_lower, yellow_upper)
    white_mask = cv2.inRange(hls, lower, upper)
    mask = cv2.bitwise_or(yellow_mask, white_mask)
    masked = cv2.bitwise_and(image, image, mask = mask)

    return masked

※ HLS (Hue, Luminanse, Saturation) :

lower = ([minimum_blue, minimum_green, minimum_red])
upper = ([maximum_blue, maximum_green, maximum_red])

더 자세한 내용은 opencv 홈페이지를 참조하시기 바랍니다.
docs.opencv.org/3.4/de/d25/imgproc_color_conversions.html

 

OpenCV: Color conversions

See cv::cvtColor and cv::ColorConversionCodes Todo:document other conversion modes RGB \(\leftrightarrow\) GRAY Transformations within RGB space like adding/removing the alpha channel, reversing the channel order, conversion to/from 16-bit RGB color (R5:G6

docs.opencv.org

hls

노란색 차선과 흰색 차선을 각각 필터링하여 bitwise_or로 합해준 mask를 원본 이미지와 bitwise_and로 합해준다면 mask부분만 남게 됩니다.

결과 이미지 :

masked

 

4. ROI

def roi(image):
    x = int(image.shape[1])
    y = int(image.shape[0])

    # 한 붓 그리기
    _shape = np.array(
        [[int(0.1*x), int(y)], [int(0.1*x), int(0.1*y)], [int(0.4*x), int(0.1*y)], [int(0.4*x), int(y)], [int(0.7*x), int(y)], [int(0.7*x), int(0.1*y)],[int(0.9*x), int(0.1*y)], [int(0.9*x), int(y)], [int(0.2*x), int(y)]])

    mask = np.zeros_like(image)

    if len(image.shape) > 2:
        channel_count = image.shape[2]
        ignore_mask_color = (255,) * channel_count
    else:
        ignore_mask_color = 255

    cv2.fillPoly(mask, np.int32([_shape]), ignore_mask_color)
    masked_image = cv2.bitwise_and(image, mask)

    return masked_image

검은색 이미지(mask)를 생성한 후, 다각형을 _shape의 형태로 생성하면 bitwise_and로 원본 이미지에서 _shape의 형태만큼의 픽셀이 복사됩니다. 만약 채널이 2채널 이상일 때는 mask의 채널도 맞춰줍니다.

아래 그림은 _shape에 대한 점을 순서대로 나타낸 것입니다.

결과 이미지 :

ROI 전                                                                                                                                              ROI 후

 

5. Threshold

_gray = cv2.cvtColor(w_f_r_img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(_gray, 160, 255, cv2.THRESH_BINARY)

이진화를 하기 위해서는 하나의 채널을 가진 gray scale 이미지로 바꿔주어야 합니다.

※ Threshold(이진화) :

  • cv2.THRESH_BINARY
  • cv2.THRESH_BINARY_INV
  • cv2.THRESH_TRUNC
  • cv2.THRESH_TOZERO
  • cv2.THRESH_TOZERO_INV

아래 그림은 임계값이 127일 때의 결과 이미지입니다.

이 외에도 cv.ADAPTIVE_THRESH_MEAN_C, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_OTSU 등이 있고 적절한 함수를 쓰시면 됩니다.

                                     ADAPTIVE_THRESH                                                                                                                                OTSU                                                                                                       

docs.opencv.org/master/d7/d1b/group__imgproc__misc.html#gaa9e58d2860d4afa658ef70a9b1115576

 

OpenCV: Miscellaneous Image Transformations

maskOperation mask that should be a single-channel 8-bit image, 2 pixels wider and 2 pixels taller than image. Since this is both an input and output parameter, you must take responsibility of initializing it. Flood-filling cannot go across non-zero pixels

docs.opencv.org

이처럼 이진화 옵션의 종류는 다양하지만 저는 THRESH_BINARY를 사용하여 임계값(160) 이하의 값은 검은색으로, 이상의 값은 255(하얀색)으로 나타나게 했습니다.

결과 이미지 :

thresh

 

6. Histogram

def plothistogram(image):
    histogram = np.sum(image[image.shape[0]//2:, :], axis=0)
    midpoint = np.int(histogram.shape[0]/2)
    leftbase = np.argmax(histogram[:midpoint])
    rightbase = np.argmax(histogram[midpoint:]) + midpoint
    
    return leftbase, rightbase
!! 여기서 histogram은 opencv 함수 histogram을 사용하지 않았습니다.

이진화한 이미지는 하나의 채널과 0~255로 이루어진 이미지입니다. 그리고 차선이 있는 부분은 대부분 255에 근접한 값을 가질 것이고 차선이 없는 부분은 0에 근접한 값을 가질 것입니다. 그래서 생각한 방법이 바로 하나의 열에 대해 모든 행의 값을 더하면 차선이 있는 부분의 열에는 매우 큰 값이, 차선이 없는 부분에는 매우 작은 값이 나타날 것이라고 생각했습니다.

그렇게 실행한 결과, 한 프레임에 대한 histogram의 결과 그래프를 보면 380정도에 왼쪽 차선이, 1050정도에 오른쪽 차선이 있다는 것을 알 수 있게 되었습니다.

결과 이미지 :

 

7.Window ROI

def slide_window_search(binary_warped, left_current, right_current):
    out_img = np.dstack((binary_warped, binary_warped, binary_warped))

    nwindows = 4
    window_height = np.int(binary_warped.shape[0] / nwindows)
    nonzero = binary_warped.nonzero()  # 선이 있는 부분의 인덱스만 저장 
    nonzero_y = np.array(nonzero[0])  # 선이 있는 부분 y의 인덱스 값
    nonzero_x = np.array(nonzero[1])  # 선이 있는 부분 x의 인덱스 값 
    margin = 100
    minpix = 50
    left_lane = []
    right_lane = []
    color = [0, 255, 0]
    thickness = 2

    for w in range(nwindows):
        win_y_low = binary_warped.shape[0] - (w + 1) * window_height  # window 윗부분
        win_y_high = binary_warped.shape[0] - w * window_height  # window 아랫 부분
        win_xleft_low = left_current - margin  # 왼쪽 window 왼쪽 위
        win_xleft_high = left_current + margin  # 왼쪽 window 오른쪽 아래
        win_xright_low = right_current - margin  # 오른쪽 window 왼쪽 위 
        win_xright_high = right_current + margin  # 오른쪽 window 오른쪽 아래

        cv2.rectangle(out_img, (win_xleft_low, win_y_low), (win_xleft_high, win_y_high), color, thickness)
        cv2.rectangle(out_img, (win_xright_low, win_y_low), (win_xright_high, win_y_high), color, thickness)
        good_left = ((nonzero_y >= win_y_low) & (nonzero_y < win_y_high) & (nonzero_x >= win_xleft_low) & (nonzero_x < win_xleft_high)).nonzero()[0]
        good_right = ((nonzero_y >= win_y_low) & (nonzero_y < win_y_high) & (nonzero_x >= win_xright_low) & (nonzero_x < win_xright_high)).nonzero()[0]
        left_lane.append(good_left)
        right_lane.append(good_right)
        # cv2.imshow("oo", out_img)

        if len(good_left) > minpix:
            left_current = np.int(np.mean(nonzero_x[good_left]))
        if len(good_right) > minpix:
            right_current = np.int(np.mean(nonzero_x[good_right]))

    left_lane = np.concatenate(left_lane)  # np.concatenate() -> array를 1차원으로 합침
    right_lane = np.concatenate(right_lane)

    leftx = nonzero_x[left_lane]
    lefty = nonzero_y[left_lane]
    rightx = nonzero_x[right_lane]
    righty = nonzero_y[right_lane]

    left_fit = np.polyfit(lefty, leftx, 2)
    right_fit = np.polyfit(righty, rightx, 2)

    ploty = np.linspace(0, binary_warped.shape[0] - 1, binary_warped.shape[0])
    left_fitx = left_fit[0] * ploty ** 2 + left_fit[1] * ploty + left_fit[2]
    right_fitx = right_fit[0] * ploty ** 2 + right_fit[1] * ploty + right_fit[2]

    ltx = np.trunc(left_fitx)  # np.trunc() -> 소수점 부분을 버림
    rtx = np.trunc(right_fitx)

    out_img[nonzero_y[left_lane], nonzero_x[left_lane]] = [255, 0, 0]
    out_img[nonzero_y[right_lane], nonzero_x[right_lane]] = [0, 0, 255]

    plt.imshow(out_img)
    plt.plot(left_fitx, ploty, color = 'yellow')
    plt.plot(right_fitx, ploty, color = 'yellow')
    plt.xlim(0, 1280)
    plt.ylim(720, 0)
    plt.show()

    ret = {'left_fitx' : ltx, 'right_fitx': rtx, 'ploty': ploty}

    return ret
!! cv2.HoughLines()나 cv2.HoughLinesP()를 사용하지 않은 이유 : HoughLine 함수들은 무겁기도 하고 곡선에 대한 차선 인식이 정확하지 않기 때문에 사용하지 않았습니다.

left_current = 이미지의 왼쪽에 있는 값 중 가장 큰 값을 가진 인덱스
good_left = window 안에 있는 부분만을 저장
다음 window의 left_current는 good_left의 길이가 50보다 작으면 nonzero_x의 인덱스 good_left의 값을 가지는 인자들의 mean값.

※ np.concatenate : Array를 1차원 배열으로 만들어 줌.
※ np.trunc : 소수점 부분을 버림.

polyfit에 대한 참고 블로그 : pinkwink.kr/1127

 

Numpy의 polyfit과 poly1d의 사용법 - 최소제곱법과 polynomial class

제가 아주 예전에 공업수학 연재를 하면서 최소제곱법을 소개했던 적이 있습니다. 에러의 제곱의 합을 최소화하는 공업수학적 방법인데 아주 유용합니다. 그리고, 이를 이용한 Python의 Numpy 함수

pinkwink.kr

결과 이미지 :

out_img

 

8. Draw Line

def draw_lane_lines(original_image, warped_image, Minv, draw_info):
    left_fitx = draw_info['left_fitx']
    right_fitx = draw_info['right_fitx']
    ploty = draw_info['ploty']

    warp_zero = np.zeros_like(warped_image).astype(np.uint8)
    color_warp = np.dstack((warp_zero, warp_zero, warp_zero))

    pts_left = np.array([np.transpose(np.vstack([left_fitx, ploty]))])
    pts_right = np.array([np.flipud(np.transpose(np.vstack([right_fitx, ploty])))])
    pts = np.hstack((pts_left, pts_right))

    mean_x = np.mean((left_fitx, right_fitx), axis=0)
    pts_mean = np.array([np.flipud(np.transpose(np.vstack([mean_x, ploty])))])

    cv2.fillPoly(color_warp, np.int_([pts]), (216, 168, 74))
    cv2.fillPoly(color_warp, np.int_([pts_mean]), (216, 168, 74))

    newwarp = cv2.warpPerspective(color_warp, Minv, (original_image.shape[1], original_image.shape[0]))
    result = cv2.addWeighted(original_image, 1, newwarp, 0.4, 0)

    return pts_mean, result

fillPoly함수로 왼쪽 선과 오른쪽 선을 포함하는 다각형을 그리고, pts_mean은 선과 선 사이의 center값으로 휘어진 곡선의 정도 등을 알 수 있습니다.

마지막으로 함수 warpping에서 가져온 값 minv를 이용해 원근감을 다시 준 이미지에 addWeighted함수로 다각형 색을 연하게 합성하는 작업으로 최종 결과물이 나타나게 됩니다.

결과 이미지 :

fillPoly한 이미지                                                                                                                                        최종 이미지

 

9. 코드

- 바닥에 있는 글씨나 앞 차가 차선을 밟을 때 생기는 불인식에 대한 수정 필요.

import cv2
import youtube_dl
import pafy
import numpy as np
import matplotlib.pyplot as plt
import time

url = 'https://www.youtube.com/watch?v=ipyzW38sHg0'
video = pafy.new(url)
best = video.getbest(preftype = 'mp4')

cap = cv2.VideoCapture(best.url)

ym_per_pix = 30 / 720
xm_per_pix = 3.7 / 720


frame_size = (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))

fourcc = cv2.VideoWriter_fourcc(*'XVID')
out1 = cv2.VideoWriter('C:\\Users\\bit\\Desktop\\opencv_youtube.mp4', fourcc, 20.0, frame_size)

def color_filter(image):
    hls = cv2.cvtColor(image, cv2.COLOR_BGR2HLS)

    lower = np.array([20, 150, 20])
    upper = np.array([255, 255, 255])

    yellow_lower = np.array([0, 85, 81])
    yellow_upper = np.array([190, 255, 255])

    yellow_mask = cv2.inRange(hls, yellow_lower, yellow_upper)
    white_mask = cv2.inRange(hls, lower, upper)
    mask = cv2.bitwise_or(yellow_mask, white_mask)
    masked = cv2.bitwise_and(image, image, mask = mask)

    return masked

def roi(image):
    x = int(image.shape[1])
    y = int(image.shape[0])

    # 한 붓 그리기
    _shape = np.array(
        [[int(0.1*x), int(y)], [int(0.1*x), int(0.1*y)], [int(0.4*x), int(0.1*y)], [int(0.4*x), int(y)], [int(0.7*x), int(y)], [int(0.7*x), int(0.1*y)],[int(0.9*x), int(0.1*y)], [int(0.9*x), int(y)], [int(0.2*x), int(y)]])

    mask = np.zeros_like(image)

    if len(image.shape) > 2:
        channel_count = image.shape[2]
        ignore_mask_color = (255,) * channel_count
    else:
        ignore_mask_color = 255

    cv2.fillPoly(mask, np.int32([_shape]), ignore_mask_color)
    masked_image = cv2.bitwise_and(image, mask)

    return masked_image

def wrapping(image):
    (h, w) = (image.shape[0], image.shape[1])

    source = np.float32([[w // 2 - 30, h * 0.53], [w // 2 + 60, h * 0.53], [w * 0.3, h], [w, h]])
    destination = np.float32([[0, 0], [w-350, 0], [400, h], [w-150, h]])

    transform_matrix = cv2.getPerspectiveTransform(source, destination)
    minv = cv2.getPerspectiveTransform(destination, source)
    _image = cv2.warpPerspective(image, transform_matrix, (w, h))

    return _image, minv

def plothistogram(image):
    histogram = np.sum(image[image.shape[0]//2:, :], axis=0)
    midpoint = np.int(histogram.shape[0]/2)
    leftbase = np.argmax(histogram[:midpoint])
    rightbase = np.argmax(histogram[midpoint:]) + midpoint

    return leftbase, rightbase

def slide_window_search(binary_warped, left_current, right_current):
    out_img = np.dstack((binary_warped, binary_warped, binary_warped))

    nwindows = 4
    window_height = np.int(binary_warped.shape[0] / nwindows)
    nonzero = binary_warped.nonzero()  # 선이 있는 부분의 인덱스만 저장
    nonzero_y = np.array(nonzero[0])  # 선이 있는 부분 y의 인덱스 값
    nonzero_x = np.array(nonzero[1])  # 선이 있는 부분 x의 인덱스 값
    margin = 100
    minpix = 50
    left_lane = []
    right_lane = []
    color = [0, 255, 0]
    thickness = 2

    for w in range(nwindows):
        win_y_low = binary_warped.shape[0] - (w + 1) * window_height  # window 윗부분
        win_y_high = binary_warped.shape[0] - w * window_height  # window 아랫 부분
        win_xleft_low = left_current - margin  # 왼쪽 window 왼쪽 위
        win_xleft_high = left_current + margin  # 왼쪽 window 오른쪽 아래
        win_xright_low = right_current - margin  # 오른쪽 window 왼쪽 위
        win_xright_high = right_current + margin  # 오른쪽 window 오른쪽 아래

        cv2.rectangle(out_img, (win_xleft_low, win_y_low), (win_xleft_high, win_y_high), color, thickness)
        cv2.rectangle(out_img, (win_xright_low, win_y_low), (win_xright_high, win_y_high), color, thickness)
        good_left = ((nonzero_y >= win_y_low) & (nonzero_y < win_y_high) & (nonzero_x >= win_xleft_low) & (nonzero_x < win_xleft_high)).nonzero()[0]
        good_right = ((nonzero_y >= win_y_low) & (nonzero_y < win_y_high) & (nonzero_x >= win_xright_low) & (nonzero_x < win_xright_high)).nonzero()[0]
        left_lane.append(good_left)
        right_lane.append(good_right)
        # cv2.imshow("oo", out_img)

        if len(good_left) > minpix:
            left_current = np.int(np.mean(nonzero_x[good_left]))
        if len(good_right) > minpix:
            right_current = np.int(np.mean(nonzero_x[good_right]))

    left_lane = np.concatenate(left_lane)  # np.concatenate() -> array를 1차원으로 합침
    right_lane = np.concatenate(right_lane)

    leftx = nonzero_x[left_lane]
    lefty = nonzero_y[left_lane]
    rightx = nonzero_x[right_lane]
    righty = nonzero_y[right_lane]

    left_fit = np.polyfit(lefty, leftx, 2)
    right_fit = np.polyfit(righty, rightx, 2)

    ploty = np.linspace(0, binary_warped.shape[0] - 1, binary_warped.shape[0])
    left_fitx = left_fit[0] * ploty ** 2 + left_fit[1] * ploty + left_fit[2]
    right_fitx = right_fit[0] * ploty ** 2 + right_fit[1] * ploty + right_fit[2]

    ltx = np.trunc(left_fitx)  # np.trunc() -> 소수점 부분을 버림
    rtx = np.trunc(right_fitx)

    out_img[nonzero_y[left_lane], nonzero_x[left_lane]] = [255, 0, 0]
    out_img[nonzero_y[right_lane], nonzero_x[right_lane]] = [0, 0, 255]

    # plt.imshow(out_img)
    # plt.plot(left_fitx, ploty, color = 'yellow')
    # plt.plot(right_fitx, ploty, color = 'yellow')
    # plt.xlim(0, 1280)
    # plt.ylim(720, 0)
    # plt.show()

    ret = {'left_fitx' : ltx, 'right_fitx': rtx, 'ploty': ploty}

    return ret

def draw_lane_lines(original_image, warped_image, Minv, draw_info):
    left_fitx = draw_info['left_fitx']
    right_fitx = draw_info['right_fitx']
    ploty = draw_info['ploty']

    warp_zero = np.zeros_like(warped_image).astype(np.uint8)
    color_warp = np.dstack((warp_zero, warp_zero, warp_zero))

    pts_left = np.array([np.transpose(np.vstack([left_fitx, ploty]))])
    pts_right = np.array([np.flipud(np.transpose(np.vstack([right_fitx, ploty])))])
    pts = np.hstack((pts_left, pts_right))

    mean_x = np.mean((left_fitx, right_fitx), axis=0)
    pts_mean = np.array([np.flipud(np.transpose(np.vstack([mean_x, ploty])))])

    cv2.fillPoly(color_warp, np.int_([pts]), (216, 168, 74))
    cv2.fillPoly(color_warp, np.int_([pts_mean]), (216, 168, 74))

    newwarp = cv2.warpPerspective(color_warp, Minv, (original_image.shape[1], original_image.shape[0]))
    result = cv2.addWeighted(original_image, 1, newwarp, 0.4, 0)

    return pts_mean, result
while True:
    retval, img = cap.read()
    if not retval:
        break

    ## 조감도 wrapped img
    wrapped_img, minverse = wrapping(img)
    # cv2.imshow('wrapped', wrapped_img)

    ## 조감도 필터링
    w_f_img = color_filter(wrapped_img)
    # cv2.imshow('w_f_img', w_f_img)

    ##조감도 필터링 자르기
    w_f_r_img = roi(w_f_img)
    # cv2.imshow('w_f_r_img', w_f_r_img)

    ## 조감도 선 따기 wrapped img threshold
    _gray = cv2.cvtColor(w_f_r_img, cv2.COLOR_BGR2GRAY)
    ret, thresh = cv2.threshold(_gray, 160, 255, cv2.THRESH_BINARY)
    # cv2.imshow('threshold', thresh)

    ## 선 분포도 조사 histogram
    leftbase, rightbase = plothistogram(thresh)
    # plt.plot(hist)
    # plt.show()

    ## histogram 기반 window roi 영역
    draw_info = slide_window_search(thresh, leftbase, rightbase)
    # plt.plot(left_fit)
    # plt.show()

    ## 원본 이미지에 라인 넣기
    meanPts, result = draw_lane_lines(img, thresh, minverse, draw_info)
    # cv2.imshow("result", result)

    ## 동영상 녹화
    out1.write(result)

    key = cv2.waitKey(25)
    if key == 27:
        break


if cap.isOpened():
    cap.release()

cv2.destroyAllWindows()

 

감사합니다.