본문 바로가기

IT/OPENCV

[OPENCV] 10. 이미지 프로세싱(5) - 허프 변환

반응형

☞ 메인보드 : Jetson Nano Developer Kit

운영 체제 : Ubuntu 18.04 - JetPack 4.4.1

☞ IDE : Visual Studio Code

☞ 언어 : C++

 

 

 

 


목차

1. 허프 변환

2. 코드 작성

3. 실행 결과

 

 

 


 

 

 

 

① 허프 변환

이미지나 영상을 봤을 때 인간들의 눈에는 직선이지만 기계가 직선으로 인식할 수 있도록 하는 것은 참으로 어려운 일이다. 직선을 검출하기 위해서는 직선으로 추정되는 픽셀들이 일직선 상에 존재하는지 확인해야 한다. OPENCV는 직선을 판별할 수 있는 허프 변환(Hough Transform)이라는 함수를 제공하고 있는데 허프 변환은 디지털 이미지, 영상 처리에 사용되는 기술이다. 이진화 이미지에서 검출된 모든 점들을 대상으로 직선을 그려 접하는 픽셀의 값을 증가시키고 그 후에 누적된 값 중에 가장 큰 값을 가질수록 직선으로써 적절하다는 알고리즘을 사용하고 있다.

 

 

 

b = -xa + y

 

 

x와 y로 이루어진 2차원 좌표에서 직선의 형태를 결정하는 기울기 a와 y절편인 b에 대한 식으로 바꿔 매개변수 공간에서 x와 y를 상수로 표현했다. 원점에서 직선까지의 수직 거리(r)와 원점에서 직선에 수선을 내렸을 때 x축과 이루는 각도(θ)를 고려하여 직선을 검출하려 했으나 직선이 수직선일 경우에 기울기 값이 무한대로 수렴하기 때문에 다양한 직선을 표현하기에 부적절하다는 결론을 내렸다.

 

 

 

 

수직선을 표현할 수 없기 때문에 직교 좌표계의 직선의 방정식을 극좌표계 형식의 직선의 방정식으로 바꾸어 사용했고, (x, y)가 아닌 (r,θ)를 사용해 그래프를 나타냈다. 직선 검출을 위해 Canny와 같은 가장자리 검출기로 출력된 픽셀들이 직선 위에 있는지 판별해야하는데 이때  r과 θ 값처럼 연속적인 값을 일정한 간격으로 쪼개서 저장하는 양자화 과정을 거친다. θ값을 촘촘히 나누면 연산에 오랜 시간을 소비하지만 정밀한 검출이 가능하고 θ값을 듬성듬성 나누면 연산은 빠르나 검출의 정확도가 낮아질 수 있다.

 

 

 

출처 : 위키피디아

 

 

좌표계에 존재하는 모든 점은 누산기로써 사용된다. 누적 값을 부여한다. 누산기는 연산 기억장치인데 단순히 덧셈, 뺄셈 등 연산이 이루어진 것들을 기억하는 저장 장치로 생각하면 된다. 이는 양자화를 거친 값을 고려하여 누적 값을 부여하는데 가장자리로 검출된 픽셀을 기준으로 직선이 회전할 때 직선과의 교점이 많을수록 누적 값을 늘리고 이를 반복하여 나온 값 중 최댓값이 나오는 픽셀을 직선의 기준으로 추정할 수 있다. 누산기에 의해 감지된 직선을 확정할 때는 houghLines 함수에서 사용자가 설정한 threshold 값에 의해 결정된다. 기본적인 원리는 여기까지이고 직접 함수의 매개변수 값을 입력해보면서 사용해보자.

 

 

 

 

 


② 코드 작성

※ HoughLines

void cv::HoughLines( InputArray image, OutputArray lines, double rho, double theta, int threshold, double srn = 0, double stn = 0, double min_theta = 0, double max_theta = CV_PI )	

 

  • rho : 원점에서 직선까지의 수직 거리
  • theta : 원점에서 직선에 수선을 내렸을 때 x축과 이루는 각도
  • threshold : 임계값, 값이 작을수록 검출 기준을 낮춰 많은 직선을 검출하지만 정확도가 낮다. 반대로 값이 크다면 검출 정확도가 높아진다.

 

 

 

※ HoughLinesP

void cv::HoughLinesP( InputArray image, OutputArray lines, double rho, double theta, int threshold, double minLineLength = 0, double maxLineGap = 0 )	

 

  • minLineLength : 검출하고자 하는 선분의 최소 길이
  • maxLineGap : 같은 선 위에 있는 점 사이의 최대 간격

 

 

 

※ 소스 코드

 

#include "opencv2/imgproc.hpp"
#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui.hpp"
#include <iostream>

//Warping Image Parameter
float widthA, widthB, heightA, heightB;
float maxWidth, maxHeight;

//HoughLinesP Parameter
double rho = 1;
double theta = CV_PI/180;
int threshold = 10;
double minLineLength = 0;
double maxLineGap = 0;

// MAKING ROI MASK
cv::Mat regionOfInterset(cv::Mat img, cv::Point *vertices){
    cv::Mat mask = cv::Mat::zeros(img.size(), CV_8UC3);

    const cv::Point* ppt[1] = { vertices };
    int npt[] = { 4 };

    cv::fillPoly(mask, ppt, npt, 1, cv::Scalar::all(255), cv::LINE_8);
    
    cv::Mat dst;
    cv::bitwise_and(img, mask, dst);

    return dst;
}

// FIND WHITE COLOR(ROAD LINE)
cv::Mat findColorHSV(cv::Mat img){
    cv::Mat HSV;    
    cv::cvtColor(img, HSV, cv::COLOR_BGR2HSV);

    cv::Mat mask_white = cv::Mat::zeros(img.size(), CV_8UC1);
    cv::Scalar lower_white = cv::Scalar(0,0,200);
    cv::Scalar upper_white = cv::Scalar(180,50,255);
    cv::inRange(HSV, lower_white, upper_white, HSV);

    return HSV;
}

// CALCULATING MAX WIDTH, MAX HEIGHT SIZE FOR WARP
void maxValueCalculator(cv::Point2f srcPt[4]){
    widthA = sqrt((pow((srcPt[0].x - srcPt[1].x),2)) + (pow((srcPt[0].y - srcPt[1].y),2)));
    widthB = sqrt((pow((srcPt[2].x - srcPt[3].y),2)) + (pow((srcPt[2].y - srcPt[3].y),2)));
    maxWidth = std::max(int(widthA), int(widthB));

    heightA = sqrt((pow((srcPt[0].x - srcPt[3].x),2)) + (pow((srcPt[0].y - srcPt[3].y),2)));
    heightB = sqrt((pow((srcPt[1].x - srcPt[2].x),2)) + (pow((srcPt[1].y - srcPt[2].y),2)));
    maxHeight = std::max(int(heightA), int(heightB));
}

// DETECTING EDGE LINES
// SET line_row_theshold, line_high_threshold
// GOT A LINE VALUE WITHIN THE RANGE OF SLOPE
void edgeLines(cv::Mat img, cv::Mat &line_result, std::vector<cv::Vec4i> lines){
    int width = img.rows;
    
    for(size_t i = 0; i < lines.size(); i++){
        cv::Vec4i line = lines[i];
        int x1 = line[0];
        int y1 = line[1];
        int x2 = line[2];
        int y2 = line[3];

        float slope;
        if(x2 == x1){ slope = 999.; }
        else{ slope = fabsf((y1 - y2) / float(x1 - x2)); }

        float line_row_theshold = tan(15*CV_PI/180);
        float line_high_threshold = tan(89.9*CV_PI/180);

        if (line_row_theshold < slope && slope < line_high_threshold){
            if(x1 < width / 2){
                // left side edge
                cv::line(line_result, cv::Point(x1, y1), cv::Point(x2, y2), cv::Scalar(255,0,0), 2, cv::LINE_AA);
            } else {
                // right side edge
                cv::line(line_result, cv::Point(x1, y1), cv::Point(x2, y2), cv::Scalar(0,255,0), 2, cv::LINE_AA);
            }
        }
    }
}

int main(){
    cv::Mat src;
    src = cv::imread("curve2.jpg", cv::IMREAD_COLOR);
    cv::resize(src, src, cv::Size(src.cols/2, src.rows/2));
    if(src.empty()){
        std::cout << "Can't Open Image" << std::endl;
        return -1;
    }

    // ROI - RECTANGULAR POINT
    cv::Point rect[4];
    rect[0] = cv::Point(0,200);
    rect[1] = cv::Point(0,293);
    rect[2] = cv::Point(440,293);
    rect[3] = cv::Point(440,200);
    
    cv::Mat ROI = regionOfInterset(src, rect);
    cv::Mat HSV = findColorHSV(ROI);
    
    cv::Mat filtering, closing;
    cv::bilateralFilter(HSV, filtering, 5, 100, 100);
    cv::morphologyEx(filtering, closing, cv::MORPH_CLOSE, cv::Mat());
    
    // WARP
    // ORIGINAL IMAGE WIDTH, HEIGHT SIZE
    cv::Point2f srcPt[4];
    srcPt[0] = cv::Point2f(0,200);
    srcPt[1] = cv::Point2f(0,293);
    srcPt[2] = cv::Point2f(440,200);
    srcPt[3] = cv::Point2f(440,293);
    maxValueCalculator(srcPt);

    // RESULT IAMGE WIDTH, HEIGHT SIZE
    cv::Point2f dstPt[4];
    dstPt[0] = cv::Point2f(0,0);
    dstPt[1] = cv::Point2f(0, maxHeight - 1);
    dstPt[2] = cv::Point2f(maxWidth - 1,0);
    dstPt[3] = cv::Point2f(maxWidth - 1, maxHeight - 1);

    cv::Mat warping;
    cv::Mat M = cv::getPerspectiveTransform(srcPt, dstPt);
    cv::warpPerspective(closing, warping, M, cv::Size(maxWidth,maxHeight));

    cv::Mat canny;
    cv::Canny(closing, canny, 150, 270);

    // HOUGH TRANSFORM
    std::vector<cv::Vec4i> lines;
    cv::HoughLinesP(canny, lines, rho, theta, threshold, minLineLength, maxLineGap);

    cv::Mat line_result = cv::Mat::zeros(src.size(), CV_8UC3);
    edgeLines(src, line_result, lines);

    cv::Mat result; 
    cv::addWeighted(line_result, 1, src, 0.6, 0., result);

    cv::imshow("ORIGINAL", src);
    cv::imshow("RESULT", result);
    
    cv::waitKey(0);
    return 0;

}

 

 

 

 

 


③ 실행 결과

 

 

 

 

 

HoughLinesP로부터 그려진 선은 기울기라는 조건에 의해서 검출이 결정되는데 이를 조절하는 변수를 2가지 만들었다. line_low_threshold와 line_high_threshold는 각각 검출될 기울기의 최소, 최댓값으로 기준을 낮게 잡으면 결과 값으로 나타난 선을 조금 더 볼 수 있다. 조건문을 달리 사용하면 이미지의 왼쪽, 오른쪽 라인을 달리 조건을 부여해 검출할 수도 있다.

 

코드를 읽으면 누군가는 이렇게 얘기할거다 "그래서 Warp는 결국 어디에 썼는데?"라고, 사실은 직접 작성한 코드와 위에 올린 코드를 살펴보면 사이사이에 빼먹은 것들이 매우 많다. 원래는 left_lines, right_lines라는 이름의 vector 배열을 만들어서 HoughLinesP에 인식된 값을 왼쪽 선이냐 오른쪽 선이냐에 따라 index로 저장한 후에 이를 기준으로 오른쪽으로 들어가는 커브인지 왼쪽으로 들어가는 커브인지 판단할 기준으로써 보는 등 다양한 방법으로 차선을 인식하고자 아이디어를 생각했다.(아직 완성하지 못해서 안 올렸지만...)

 

인터넷에 검색해보면 Warp 효과로 출력된 이미지의 시선을 새가 위에서 아래를 본다고 하여 Bird Eye's View 라고도 불린다. 나는 이전 포스트에 작성했듯 Warp를 사용하여 얻을 수 있는 것이 2가지 있다고 생각한다. 첫째로, 원근 효과에 의한 낮은 인식률을 피할 수 있다. ROI를 지정할 때 직사각형이나 사다리꼴 모양의 Mask를 만들어 원본 이미지에만 맞는 직사각형 이미지로 결과를 내도 이미지 상에서 직선 같지 않았던 부분을 직선처럼 표현할 수 있게 된다. 실제로 이미지에서 직선같이 보이지 않더라도 우리는 그 선이 직선인 것을 안다. 그러나 로봇은 그렇지 않다. 움직임에는 근거가 있어야 하고 그 근거는 청각이나 촉각이 될 수 없다. 오로지 카메라인 시각에 의존하게 된다. 로봇이 어렵지 않게 인식할 수 있는 정도의 개발을 해야지만 로봇이 잘 굴러갈 것이다. 둘째로, 직선뿐만 아니라 곡선도 직선처럼 볼 수 있게 해 준다. 차선 인식을 공부하면서 Github나 구글링을 열심히 했지만 Warp를 사용해서 차선을 인식한 코드는 별로 없다. 별로 안 썼다는 건 큰 차이가 없거나 대안이 있기 때문일지도 모른다. 그러나 위 두 가지 이유로 Warp 함수를 잘 사용해서 차선을 인식하면 좋을 것이라고 생각한다.

 

 

 

이야기가 매우 길어졌는데 위에 올려놓은 코드에서 Warp 기능을 제대로 담아내지 못했다는 핑계였다. HoughLinesP의 원리를 공부하기 위해 하지도 않던 수학 공식을 찾아본 포스트였다. 로봇에 대해 잘 이해하려면 선형대수가 필수라는데 이것 참 손에 안 잡히는건 아직 뒤지게 맞아보지 않아서 그런가 보다 호홋

 

 

 

 

 

반응형