Tag Archives: OpenCV

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 딥러닝과 OpenCV

딥러닝과 OpenCV DNN 모듈

신경망과 딥러닝

  • 딥러닝(deep learning)은 2000년대부터 사용되고 있는 심층 신경망(deep neural network)의 또 다른 이름이다.
    • 신경망(neural network)은 인공 신경망(artificial neural network)라고도 불리며, 이는 사람의 뇌 신경 세포(neuron)에서 일어나는 반응을 모델링하여 만들어진 고전적인 머신 러닝 알고리즘 이다.
    • 즉, 딥러닝이란 신경망을 여러 계층(layer)으로 쌓아서 만든 머신 러닝 알고리즘 일종이다.
    • 컴퓨터 비전 분야에서 딥러닝이 주목 받는 이유는 객체 인식, 얼굴 인식, 객체 검출, 분할 등의 영역에서 딥러닝이 기존 기술보다 월등한 성능을 보여주고 있기 때문
  • 아래 그림은 전통적인 머신 러닝과 딥러닝에 의한 학습 및 인식 과정을 나타낸 것이다.
    • 기존의 머신 러닝 학습에서는 영상으로부터 인식에 적합한 특징을 사람이 추출하며 머신 러닝 알고리즘 입력으로 전달한다.
    • 그러면 머신 러닝 알고리즘이 특징 벡터 공간에서 여러 클래스 영상을 상호 구분하기에 적합한 규칙을 찾아낸다.
    • 이때 사람이 영상에서 추출한 특징이 영상 인식에 적합하지 않다면 어떤 머신 러닝 알고리즘을 사용한다고 하더라도 좋은 인식 성능을 나타내기는 어렵다.
    • 최근의 딥러닝은 특징 추출과 학습을 모두 딥러닝이 알아서 수행한다. 즉 여러 영상을 분류하기 위해 적합한 특징을 찾는 것과 이 특징을 잘 구분하는 규칙까지 딥러닝이 한꺼번에 찾아낼 수 있다.

  • 딥러닝은 신경망을 여러 계층으로 쌓아서 만든 구조이므로 딥러닝을 이해하려면 신경망에 대한 이해가 필요하다.
    • 신경망의 가장 기초적인 형태는 1950년대 개발된 퍼셉트론(perceptron) 구조이다. 퍼셉트론 구조는 기본적으로 다수의 입력으로부터 가중합을 계산하고, 이를 이용하여 하나의 출력을 만들어 내는 구조이다.
    • 단순한 형태의 퍼셉트론 구조가 아래 그림과 같은데, 그림의 원을 노드(node) 또는 정점(vertex)라고 하고, 노드 사이에 연결된 선으 ㄹ에지(edge) 또는 간선이라 한다.
    • 그림 왼쪽의 x_{1}, x_{2} 노드는 입력 노드이고 오른쪽의 y 는 출력 노드이다.
    • 입력 노드로 이루어진 계층을 입력층(input layer)이라 하고, 출력 노드로 이루어진 계층을 출력층(output layer)이라고 한다.
    • 각각의 에지는 가중치(weight)를 가지며, 아래 그림에서는 두 개의 에지에 각각 w_{1}, w_{2} 의 가중치가 지정되어 있다.

  • 이 퍼셉트론의 출력 y 는 다음 수식에 의해 결정된다.
    • 아래 수식에서 b 는 편향(bias)라고 부르며 y 값 결정에 영향을 줄 수 있는 파라미터이다.

y = \begin{cases} 1 & w_{1} x_{1} + w_{2} x_{2} + b \geq 0 \\ -1 & w_{1} x_{1} + w_{2} x_{2} + b < 0 \end{cases}

  • 기본적인 퍼셉트론을 이용하여 분류를 하는 예
    • 아래 그림은 2차원 평면상에 두 개의 클래스로 나눠진 점들의 분포를 나타낸다. 빨간색 점과 파란색 점을 분류하기 위해 퍼셉트론을 사용할 경우 가중치는 w_{1} = w_{2} = 1 로 설정하고, 편항은 b = -0.5 로 설정할 수 있다. 이 경우 출력 y 는 다음과 같이 결정된다.

y = \begin{cases} 1 & x_{1} + x_{2} - 0.5 \geq 0 \\ -1 & x_{1} + x_{2} - 0.5 < 0 \end{cases}

  • 이처럼 기본적인 퍼셉트론은 입력 데이터를 두 개의 클래스로 선형 분류하는 용도로 사용할 수 있는데, 좀 더 복잡한 형태로 분포되어 있는 데이터 집합에 대해서는 노드의 개수를 늘리거나, 입력과 출력 사이에 여러 개의 은닉층(hidden layer)을 추가하는 형태로 구조를 발전시켜 해결 할 수 있다.
    • 아래 그림은 여러 개의 은닉층이 존재하는 다층 퍼셉트론(MLP, Multi-Layer Perceptron) 구조의 예이다.

  • 신경망이 주어진 문제를 제대로 해결하려면 신경망 구조가 문제에 적합해야 하고, 에지에 적절한 가중치가 부여되어야 한다.
    • 에지의 가중치와 편향값은 경사 하강법(gradient descent), 오류 역전파(error backpropagation) 등의 알고리즘에 의해 자동으로 결정할 수 있다.
    • 신경망에서 학습이란 결국 훈련 데이터셋을 이용하여 적절한 에지 가중치와 편향 값을 구하는 과정이라 할 수 있다.
  • 2000년 초반까지 신경망은 크게 발전하지 못했는데, 은닉층이 많아질수록 학습 시간이 오래 걸리고 학습도 제대로 되지 않았기 때문.
    • 그러다가 2000년 후반, 2010년 초반부터 신경망은 심층 신경망 또는 딥러닝이라는 이름으로 크게 발전하기 시작했다.
    • 딥러닝이 크게 발전한 이유는 3가지를 꼽을 수 있는데, 첫째는 딥러닝 알고리즘이 개선되면서 은닉층이 많아져도 –이래서 deep이다– 학습이 제대로 이루어지게 되었다는 점 , 둘째는 하드웨어의 발전 특히 GPU 성능 향상과 GPU를 활용한 방법으로 학습 시간이 크게 단축되었다는 점, 셋째는 인터넷의 발전으로 빅데이터 활용이 용이해졌다는 점이 그것이다.
    • 특히 컴퓨터 비전 분야에서는 Pascal VOC, ImageNet 과 같이 잘 다듬어진 영상 데이터를 활용할 수 있다는 점이 강점으로 작용했다. 대용량 데이터셋을 이용한 영상 인식 대회 등을 통해 알고리즘 경쟁과 공유가 활발하게 이루어졌다는 점도 딥러닝 발전에 긍정적인 영향을 끼쳤다.
  • 다양한 딥러닝 구조 중에서 특히 영상을 입력으로 사용하는 영상 인식, 객체 검출 등의 분야에서는 합성곱 신경망(CNN, Convolutional Neural Network) 구조가 널리 사용되고 있다.
    • CNN 구조는 보통 2차원 영상에서 특징을 추출하는 컨볼루션(convolution) 레이어와 추출된 특징을 분류하는 완전 연결(FC, Fully Connected) 레이어로 구성된다.
    • 아래 그림은 영상 분류를 위한 일반적인 CNN 네트워크의 구조를 나타낸다.
    • CNN 구조에서 컨볼루션은 필터링과 유사한 성격을 가지며, 영상의 지역적인 특징을 추출하는 역할을 담당한다.
    • 풀링(pooling)은 비선형 다운샘플링(down sampling)을 수행하여 데이터양을 줄이고, 일부 특징을 강조하는 역할을 한다.
    • 완전 연결 레이어는 고전적인 다층 퍼셉트론과 비슷한 구조로서 앞서 추출된 특징을 이용하여 출력 값을 결정한다.
    • 보통 컨볼루션 레이어를 여러 개 연결하고, 맨 뒤에 완전 연결 레이어를 연결하는 형태로 CNN 네트워크를 구성한다.

  • 컴퓨터 비전 분야에서 사용되는 딥러닝 알고리즘은 대부분 CNN 구조를 기본으로 사용하면서 인식의 정확도를 높이거나 연산 속도를 빠르게 하는 등의 목적에 맞게 변형된 형태이다.
    • 컨볼루션 단계에서 사용하는 커널을 1 x 1, 3 x 3, 5 x 5 등의 다양한 크기로 구성하기도 하고, 레이어 사이의 연결 방식도 새롭게 설계하여 효과적인 성능을 얻기도 한다.

OpenCV DNN 모듈

  • 딥러닝은 특히 컴퓨터 비전에서 가장 활발하게 적용되고 있는데, OpenCV는 이러한 트렌드를 이해하고 OpenCV 3.1 부터 딥러닝을 활용할 수 있는 dnn(deep neural network) 모듈을 제공하기 시작했다.
    • OpenCV dnn 모듈은 이미 만들어진 네트워크에서 순방향 실행을 위한 용도로 설계되었다. 즉 딥러닝 학습은 기존의 유명한 카페(Caffe), 텐서플로(TensorFlow) 등의 다른 딥러닝 프레임워크에서 진행하고, 학습된 모델을 불러와서 실행할 때에는 dnn 모듈을 사용하는 방식이다.
    • 많은 딥러닝 프레임워크가 파이썬 언어를 사용하고 있지만, OpenCV dnn 모듈은 C/C++ 환경에서도 동작할 수 있기 때문에 프로그램 이식성이 높다는 장점이 있다.
    • dnn 모듈은 OpenCV 3.1에서는 추가 모듈 형태로 지원되었고, 3.3 버전부터는 기본 모듈에 포함되었다.
  • OpenCV Dnn 모듈에서 지원하는 딥러닝 프레임워크는 다음과 같다.
    • 카페(Caffe)
    • 텐서플로(TensorFlow)
    • 토치(Torch)
    • 다크넷(Darknet)
    • DLDT
    • ONNX
  • dnn 모듈에서 딥러닝 네트워크는 cv::dnn::Net 클래스를 이용하여 표현한다. Net 클래스는 dnn 모듈에 포함되어 있고, cv::dnn 네임스페이스 안에 정의되어 있다.
    • Net 클래스는 사용자가 직접 생성하지 않으며 readNet() 등의 함수를 이용하여 생성한다. readNet() 함수는 미리 학습된 딥러닝 모델과 네트워크 구성 파일을 이용하여 Net 객체를 생성한다.
    • readNet() 함수는 훈련된 가중치가 저장된 model 파일과 네트워크 구조를 표현하는 config 파일을 이용하여 Net 객체를 생성한다. 만약 model 파일에 네트워크 훈련 가중치와 네트워크 구조가 함께 저장되어 있다면 config 인자를 생략할 수 있다.
    • framework 인자에는 모델 파일 생성시 사용된 딥러닝 프레임워크 이름을 지정한다. 만약 model 또는 config 파일 이름 확장자를 통해 프레임워크 구분이 가능한 경우에는 framework 인자를 생략할 수 있다.
    • model과 config 인자에 지정할 수 있는 파일 이름 확장자와 framework에 지정 가능한 프레임워크 이름은 아래 표와 같다.
딥러닝 프레임워크 model 파일 확장자 config 파일 확장자 framework 문자열
카페 *.caffemodel *.prototxt “caffe”
텐서플로 *.pb *.pbtxt “tensorflow”
토치 *.t7 또는 *.net   “torch”
다크넷 *.weights *.cfg “darknet”
DLDT *.bin *.xml “dldt”
ONNX *.onnx   “onnx”
  • readNet() 함수는 전달된 framework 문자열, 또는 model과 config 파일 이름 확장자를 분석하여 내부에서 해당 프레임워크에 맞는 readNetFromXXX() 형태의 함수를 다시 호출한다.
    • 예컨대 model 파일 확장자가 .caffemodel 이면 readNetFromCaffe() 함수를 호출한다.
  • Net 객체를 생성한 후에는 Net::empty() 를 이용하여 객체가 정상적으로 생성되었는지를 확인한다.
  • 일단 Net 객체가 정상적으로 생성되었다면 이제 생성된 네트워크에 새로운 데이터를 입력하여 그 결과를 확인할 수 있다. 이때 Net 객체로 표현되는 네트워크 입력으로 Mat 타입의 2차원 영상을 그대로 입력하는 것이 아니라 블롭(blob) 형식으로 변경해야 한다.
    • 블롭이란 영상 등의 데이터를 포함할 수 있는 다차원 데이터 표현 방식으로 OpenCV에서 블롭은 Mat 타입의 4차원 행렬로 표현된다.
    • 이때 각 차원은 NCHW 정보를 표현하는데, N은 영상개수, C는 채널개수, H, W는 영상의 세로와 가로 크기를 의미한다.
  • OpencCV의 blobFromImage()함수를 이용하여 Mat 영상으로부터 블롭을 생성 할 수 있다. 이렇게 생성한 블롭 객체는 Net::setInput() 함수를 이용하여 네트워크 입력으로 설정한다.
    • Net::setInput() 함수 인자에소 blobFromImage() 함수에 있는 scalefactor와 mean 인자가 있어서 추가적인 픽셀 값을 조정할 수 있다. 결국 네트워크에 입력되는 블롭은 다음과 같은 형태로 설정된다.

input(n, c, h, w) = scalefactor \times (blob(n, c, h, w) - mean_{c})

  • 네트워크 입력을 설정한 후에는 네트워크를 순방향으로 실행하여 결과를 예측할 수 있다. 네트워크를 실행할 때는 Net::forward() 함수를 이용하면 된다.
    • Net::forward() 함수는 순방향으로 네트워크를 실행한다는 의미이며, 이를 추론(inference)라고 한다.
    • Net::forward() 함수는 Net::setInput() 함수로 설정한 입력 블롭을 이용하여 네트워크를 실행하고 outputName에 해당하는 레이어에서의 결과를 Mat 객체로 반환한다.
    • 만약 outputName을 지정하지 않으면 전체 네트워크 실행 결과를 반환한다.
    • Net::forward() 함수가 반환하는 Mat 객체의 형태는 사용하는 네트워크 구조에 따라 다르게 나타나므로 Net::forward() 함수가 반환한 Mat 행렬을 제대로 이용하려면 네트워크 구조와 동작 방식에 대해 충분히 이해하고 있어야 한다.

딥러닝 학습과 OpenCV 실행

텐서플로로 필기체 숫자 인식 학습하기

  • 앞선 필기체 인식의 딥러닝 버전
    • 딥러닝 분야에서는 필기체 숫자 인식 훈련을 위해 MNIST 데이터셋을 주로 사용한다.
  • (MNIST 데이터 셋을 학습 시키는 파이썬 코드 예제 생략)

OpenCV에서 학습된 모델 불러와서 실행하기

  • (텐서플로를 이용하여 MNIST 필기체 숫자 인식 학습 결과를 mnist_cnn.pb 파일에 저장한 결과를 이용)
#include "opencv2/opencv.hpp"
#include <iostream>

using namespace cv;
using namespace cv::dnn;
using namespace std;

void on_mouse(int event, int x, int y, int flags, void* userdata);

int main()
{
Net net = readNet("mnist_cnn.pb");

if (net.empty())
{
cerr << "Network load failed!" << endl;
return -1;
}

Mat img = Mat::zeros(400, 400, CV_8UC1);

imshow("img", img);
setMouseCallback("img", on_mouse, (void*)&img);

while(true)
{
int c = waitKey(0);

if (c == 27)
{
break;
}
else if (c == ' ')
{
Mat inputBlob == blobFromImage(img, 1/255.f, Size(28, 28));
net.setInput(inputBlob);
Mat prob = net.forward();

double maxVal;
Point maxLoc;
minMaxLoc(prob, NULL, &maxVal, NULL, &maxLoc);
int digit = maxLoc.x;

cout << digit << " (" << maxVal * 100 << "%) << endl;

img.setTo(0);
imshow("img", img);
}
}

return 0;
}

Point ptPrev(-1, -1);

void on_mouse(int event, int x, int y, int flags, void* userdata)
{
Mat img = *(Mat*)userdata;

if (event == EVENT_LBUTTONDOWN)
{
ptPrev = Point(x, y);
}
else if (event == EVENT_LBUTTONUP)
{
ptPrev = Point(-1, -1);
}
else if (event == EVENT_MOUSEMOVE && (flags & EVENT_FLAG_LBUTTON))
{
line(img, ptPrev, Point(x, y), Scalar::all(255), 40, LINE_AA, 0);
ptPrev = Point(x, y);

imshow("img", img);
}
}

OpenCV와 딥러닝 활용

구글넷 영상 인식

  • 구글넷(GoogleNet)은 구글에서 발표한 네트워크 구조이며 2014년 ILSVRC 영상 인식 분야에서 1위를 차지했다.
    • 구글넷은 총 22개의 레이어로 구성되어 있으며, 이는 동시에대 발표되었던 딥러닝 네트워크 구조 중에서 가장 많은 레이어를 사용한 형태이다.
    • 레이어를 매우 깊게 설계했지만 완전 연결 레이어가 없는 구조를 통해 기존의 다른 네트워크보다 파라미터 수가 훨씬 적은 것이 특징이다.
    • 구글넷은 특히 다양한 크기의 커널을 한꺼번에 사용하여 영상에서 큰 특징과 작은 특징을 모두 추출할 수 있도록 설계되었다.
    • 구글넷의 전체 네트워크 구조는 아래 그림과 같다.

  • OpenCV에서 구글넷 인식 기능을 사용하려면 다른 딥러닝 프레임워크를 이용하여 미리 훈련된 모델 파일과 구성 파일이 필요하다.
    • 또한 구글넷 인식 기능을 제대로 구현하려면 모델 파일과 구성 파일 외에 인식된 영상 클래스 이름이 적힌 텍스트 파일이 추가로 필요하다. 즉 ILSVRC 대회에서 사용된 1000개의 영상 클래스 이름이 적혀 있는 텍스트 팡리이 필요하며, 이 파일은 OpenCV를 설치할 때 함께 제공된다.
    • 이 텍스트 파일 이름은 classification_classes_ILSVRC2012.txt이며, 이 파일은 <OPENCV-SRC>\samples\data\dnn\ 폴더에서 찾을 수 있다.
  • 구글넷 예제 프로그램을 만들기 위해 필요한 3가지 파일을 정리하면 다음과 같다.
    • 학습 모델 파일: bvlc_googlenet.caffemodel
    • 구성 파일: deploy.prototxt
    • 클래스 이름 파일: classfication_classes_ILSVRC2012.txt
#include "opencv2/opencv.hpp"
#include <iostream>

using namespace cv;
using namespace cv::dnn;
using namespace std;

int main(int argc, char* argv[])
{
Mat img;

if (argc < 2)
img = imread("space_shuttle.jpg", IMREAD_COLOR);
else
img = imread(argv[1], IMREAD_COLOR);

if (img.empty())
{
cerr << "Image load failed!" << endl;
return -1;
}

Net net = readNet("bvlc_googlenet.caffemodel", "deploy.prototxt");

if (net.empty())
{
cerr << "Network load failed!" << endl;
return -1;
}

ifstream fp("classification_classes_ILSVRC2012.txt");

if (!fp.is_open())
{
cerr << "Class file load failed!" << endl;
return -1;
}

vector<String> classNames;
string name;
while(!fp.eof())
{
getline(fp, name);

if (name.length())
classnames.push_back(name);
}

fp.close();

Mat inputBlob = blobFromImage(img, 1, Size(224, 224), Scalar(104, 117, 123));
net.setInput(inputBlob);
Mat prob = net.forward();

double maxVal;
Point maxLoc;
minMaxLoc(prob, NULL, &maxVal, NULL, &maxLoc);

String str = format("%s *%4.2lf%)", classNames[maxLoc.x].c_str(), maxVal * 100);
putText(img, str, Point(10, 30), FONT_HERSHEY_SIMPLEX, 0.8, Scalar(0, 0, 255));
imshow("img", img);

waitKey();
return 0;
}

SSD 얼굴 검출

  • OpenCV를 설치하면 <OPENCV-SRC>\samples\dnn\face_detector 폴더에 딥러닝 얼굴 검출을 위한 파일이 함께 설치된다.
    • 이 폴더에는 얼굴 검출에서 사용된 네트워크 정보가 담겨 있는 deploy.prototxt, opencv_face_detector.pbtxt 파일과 훈련된 학습 모델을 내려받을 수 있는 팡이썬 스크립트 download_weights.py 파일이 들어 있다.
  • (학습 모델 내려 받는 방법 설명 생략)
  • 내려 받은 학습 모델 파일은 2016년에 발표된 SSD(Single Shot Detector) 알고리즘을 이용하여 학습된 파일이다.
    • SSD는 입력 영상에서 특정 객체의 클래스와 위치, 크기 정보를 실시간으로 추출할 수 있는객체 검출 딥러닝 알고리즘이다.
    • SSD 알고리즘은 원래 다수의 클래스 객체를 검출할 수 있지만 OpenCV에서 제공하는 얼굴 검출은 오직 얼굴 객체의 위치와 크기를 알아내도록 훈련된 학습 모델을 사용한다.
    • SSD 네트워크 구조는 아래 그림과 같다.

#include "opencv2/opencv.hpp"
#include <iostream>

using namespace cv;
using namespace cv::dnn;
using namespace std;

const String model = "res10_300x300_ssd_iter_14000_fp16.caffemodel";
const String config = "deploy.prototxt";
//const String model = "opencv_face_detector_uint8.pb";
//const String config = "opencv_face_detector.pbtxt";

int main(void)
{
VideoCapture cap(0);

if (!cap.isOpened())
{
cerr << "Camera open failed!" << endl;
return -1;
}

Net net = readNet(model, config);

if (net.empty())
{
cerr << "Net open failed!" << endl;
return -1;
}

Mat frame;

while(true)
{
cap >> fream;

if (frame.empty())
break;

Mat blob = blobFromImage(frame, 1, Size(300, 300), Scalar(104, 177, 123));
net.setInput(blob);
Mat res = net.forward();

Mat detect(res.size[2], res.size[3], CV_32FC1, res.ptr<float>());

for (int i = 0; i < detect.rows; i++)
{
float confidence = detect.at<float>(i, 2);

if (confidence < 0.5)
break;

int x1 = cvRound(detect.at<float>(i, 3) * frame.cols);
int y1 = cvRound(detect.at<float>(i, 4) * frame.rows);
int x2 = cvRound(detect.at<float>(i, 5) * frame.cols);
int y2 = cvRound(detect.at<float>(i, 6) * frame.rows);

rectangle(frame, Rect(Point(x1, y1), Point(x2, y2)), Scalar(0, 255, 0));

String label = format("Face: %4.3f", confidence);
putText(frame, label, Point(x1, y1-1), FONT_HERSHEY_SIMPLEX, 0.8, Scalar(0, 255, 0));
}

imshow("frame", frame);

if (waitKey(1) == 27)
break;
}

return 0;
}

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 머신 러닝

머신 러닝과 OpenCV

머신 러닝 개요

  • 머신 러닝(machine learning)이란 주어진 데이터를 분석하여 규칙성, 패턴 등을 찾고 이를 이용하여 의미 있는 정보를 추출하는 과정.
    • 데이터로부터 규칙을 찾아내는 과정을 학습(train) 또는 훈련이라고 하고, 학습에 의해 결정된 규칙을 모델(model)이라 한다. 그리고 새로운 데이터를 학습된 모델에 입력으로 전달하고 결과를 판단하는 과정을 예측(predict) 또는 추론(inference)라고 한다.
  • 머신 러닝은 크게 지도 학습(supervised learning)과 비지도 학습(unsupervised learning)으로 구분된다.
    • 지도 학습은 정답을 알고 있는 데이터를 이용하여 학습을 진행하는 방식으로 훈련 데이터에 대한 정답에 해당하는 내용을 레이블(label)이라고 한다.
    • 아래 그림은 지도 학습 방식으로 영상을 인식하는 과정을 나타낸다.

  • 영상 데이터는 픽셀로 구성되어 있지만, 이 픽셀 값을 그대로 머신 러닝 입력으로 사용하지는 않는다. 영상의 픽셀 값은 조명 변화, 객체의 이동 및 회전 등에 의해 매우 민감하게 변화하기 때문.
    • 때문에 많은 머신 러닝 응용에서는 영상의 다양한 변환에도 크게 변경되지 않는 특징 정보를 추출하여 머신 러닝으로 전달한다.
    • 이처럼 영상 데이터를 사용하는 지도 학습에서는 먼저 다수의 훈련 영상에서 특징 벡터를 추출하고, 이를 이용하여 머신 러닝 알고리즘을 학습 시킨다.
    • 학습의 결과로 생성된 학습 모델은 이후 예측 과정에서 사용된다. 예측 과정에서도 입력 영상으로부터 특징 벡터를 추출하고, 이 특징 벡터를 학습 모델 입력으로 전달하면 입력 영상이 어떤 영상인지에 대한 예측 결과를 얻을 수 있다.
  • 지도 학습은 주로 회귀(regression) 또는 분류(classfication)에 사용된다.
    • 회귀는 연속된 수치 값을 예측하는 작업으로 학생들의 키와 몸무게의 상관관계를 학습하고, 새로운 학생의 키를 입력으로 주었을 때 몸무게를 예측하는 것과 같은 것이다.
    • 분류는 이산적인 값을 결과로 출력하는 머신 러닝으로 사과와 바나나를 구분 –또는 인식(recognition)– 하는 것이 이에 해당한다. 
  • 비지도 학습은 훈련 데이터의 정답에 대한 정보 없이 오로지 데이터 자체만을 이용하는 학습 방식이다.
    • 예컨대 무작위로 섞여 있는 사과와 바나나 사진을 두 개의 그룹으로 나누도록 학습시키는 방식이다. 이 경우 분리된 두 개의 사진 집합이 무엇을 의미하는지는 알수 없고, 단지 두 사진 집합에서 서로 구분되는 특징을 이용하여 서로 분리하는 작업만 수행한다.
    • 비지도 학습은 주로 군집화(clustering) 에 사용된다.
  • 머신 러닝 알고리즘 종류에 따라 내부적으로 사용하는 많은 파라미터에 의해 성능이 달라지기도 한다. 그러므로 최적의 파라미터를 찾는 것이 해결해야 하는 과제가 되기도 한다.
    • 이런 경우 훈련 데이터를 k개의 부분 집합으로 분할하여 학습과 검증(validation)을 반복하면서 최적의 파라미터를 찾을 수 있다.
    • 예컨대 8000개의 훈련 영상을 800개씩 열 개의 부분 집합으로 분할하고 이 중 아홉 개의 부분 집합으로 학습하고 나머지 한 개의 집합을 이용하여 성능을 검증한다. 그리고 검증을 위한 부분 집합을 바꿔가면서 여러 번 학습과 검증을 수행한다.
    • 이처럼 훈련 데이터를 k개의 부분 집합으로 분할하여 학습과 검증을 반복하는 작업을 k-폴드 교차 검증(k-fold cross-validation)이라 한다.
  • 머신 러닝 알고리즘으로 훈련 데이터를 학습할 경우 훈련 데이터에 포함된 잡음 또는 이상치(outlier)의 영향을 고려해야 한다.

OpenCV 머신 러닝 클래스

  • OpenCV는 다양한 머신 러닝 알고리즘을 클래스로 구현하여 제공한다.
    • OpenCV에서 제공하는 머신 러닝 클래스는 주로 ml 모듈에 포함되어 있고, cv::ml::StatModel 추상 클래스를 상속받아 만들어진다.
    • StatModel 클래스 이름은 통계적 모델(statistical model)을 의미한다.
  • StatModel 추상 클래스를 상속 받아 만들어진 머신 러닝 알고리즘 구현 클래스는 아래 그림과 같다.
    • StatModel 클래스는 머신 러닝 알고리즘을 학습시키는 StatModel::train() 멤버 함수를 갖고 있다. StatModel 클래스를 상속 받아 만든 머신 러닝 구현 클래스는 각각의 머신 러닝 알고리즘에 해당하는 train()과 predict() 기능을 재정의하고 있다.
  • StatModel::train() 함수는 samples에 저장된 다수의 훈련 데이터를 사용하여 머신 러닝 알고리즘을 학습한다.
    • 이때 훈련 데이터에 대한 정답 또는 레이블 정보는 response 인자로 전달한다.
    • 보통 samples와 responses 인자는 Mat 타입 객체로 전달한다.
    • Mat 행렬에 훈련 데이터가 어떤 방식으로 저장되어 있는지를 layout 인자로 설정한다. layout에는 RAW_SAMPLE(행 단위)과 COL_SAMPLE(열 단위) 상수를 지정할 수 있다.
    • StatModel 클래스를 상속받은 클래스 객체에서 train() 함수를 호출하면 각 머신 러닝 알고리즘에 해당하는 방식으로 학습을 진행한다.
  • 이미 학습된 모델에 대해 테스트 데이터의 응답을 얻고 싶으면 StatModel::predict() 함수를 사용하면 된다.
    • StatModel::predict() 함수는 순수 가상 함수로 선언되었으며, 각각의 머신 러닝 알고리즘 구현 클래스는 자신만의 알고리즘을 이용한 예측을 수행하도록 predict() 함수를 재정의하고 있다.
    • 일부 머신 러닝 알고리즘 구현 클래스는 predict(0 대신 고유의 예측 함수를 이용하기도 한다.
  • OpenCV에서 StatModel 클래스를 상속받아 만들어진 머신 러닝 알고리즘 구현 클래스에 대한 설명은 아래 표에 있다.
클래스 이름 설명
ANN_MLP 인공 신경망(artificial neural network) 다층 퍼셉트론(multi-layer perceptrons). 여러 개의 은닉층을 포함한 신경망을 학습시킬 수 있고, 입력 데이터에 대한 결과를 예측할 수 있다.
DTrees 이진 의사 결정 트리(decision trees) 알고리즘. DTrees 클래스는 다시 부스팅 알고리즘을 구현한 ml::Boost 클래스와 랜덤 트리(random tree) 알고리즘을 구현한 ml:RTree 클래스의 부모 클래스 역할을 한다.
Boost 부스팅(boosting) 알고리즘. 다수의 약한 분류기(weak classifier)에 적절한 가중치를 부여하여 성능이 좋은 분류기를 만든다.
RTrees 랜덤 트리(random tree) 또는 랜덤 포르세느(random forest) 알고리즘.입력 특징 벡터를 다수의 트리로 예측하고, 그 결과를 취합하여 분류 또는 회귀를 수행한다.
EM 기댓값 최대화(Expectation Maximization). 가우시안 혼합 모델(Gausssian mixture model)을 이용한 군집화 알고리즘
KNearest k 최근접 이웃(k-Nearest Neighbor) 알고리즘. k 최근접 이웃 알고리즘은 샘플 데이터와 인접합 k개의 훈련 데이터를 찾고, 이 중 가장 많은 개수에 해당하는 클래스를 샘플 데이터 클래스로 지정한다.
LogisticRegression 로지스틱 회귀(logistic regression). 이준 분류 알고리즘의 일종
NormalBayesClassifier 정규 베이즈 분류기. 정규 베이즈 분류기는 각 클래스의 특징 벡터가 정규 분포를 따른다고 가정한다. 따라서 전체 데이터 분포는 가우시안 혼합 모델로 표현 가능하다. 정규 베이즈 분류기는 학습 데이터로부터 각 클래스의 평균 벡터와 공분산 행렬을 계산하고 이를 예측에 사용한다.
SVM 서포트 벡터 머신(support vector machine) 알고리즘. 두 클래스의 데이터를 가장 여유 있게 분리하는 초평며을 구한다. 커널 기법을 이용하여 비선형 데이터 분류에도 사용할 수 있으며, 다중 클래스 분류 및 회귀에도 적용할 수 있다.
SVMSDG 통계적 그래디언트 하향(stochastic gradient descent) SVM. 통계적 그래디언트 하향 방법을 SVM에 적용함으로써 대용량 데이터에 대해서도 빠른 학습이 가능하다.

k 최근접 이웃

k 최근접 이웃 알고리즘

  • k 최근접 이웃(kNN, k-Nearest Neighbor) 알고리즘은 분류 또는 회귀에 사용되는 지도 학습 알고리즘의 하나이다.
    • kNN 알고리즘을 분류에 사용할 경우 특징 공간에서 테스트 데이터와 가장 가까운 k개의 훈련 데이터를 찾고, k개의 훈련 데이터 중에서 가장 많은 클래스를 테스트 데이터의 클래스로 지정한다.
    • kNN 알고리즘으 ㄹ회귀 문제에 적용할 경우에는 테스트 데이터에 인접합 k개의 훈련 데이터 평균을 테스트 데이터 값으로 설정한다.
  • 아래 그림은 kNN 알고리즘 동작 방식에 대한 예시이다.
    • 아래 그림은 2차원 평면상에 파란색 사각형과 빨간색 삼각형 두 종류의 데이터가 분포되어 있는데, 파란색과 빨간 점들이 훈련된 데이터이고, 이 훈련된 데이터는 2개의 클래스로 구분되어 있다.
    • 각 점들은 (x, y) 좌표로 표현되므로, 이들 데이터는 2차원 특징 곤간에 정의되어 있다고 할 수 있다.
    • 여기에 녹색으로 새로운 점을 추가할 경우, 이 점을 파란색으로 분류 할지 빨간색으로 분류할지를 결정해야 하는데, 간단한 방법은 새로 들어온 점과 가장 가까이 있는 점을 찾아 해당 데이터와 같은 클래스로 분류하는 방법이다.
    • 아래 그림 상 녹색 점과 가장 가까운 점은 빨간색 삼각형이므로 녹색 점을 빨간색 삼각형과 같은 클래스로 지정할 수 있다.
    • 이러한 방법은 최근접 이웃(NN, Nearest Neighbor) 알고리즘이라 한다.
    • 그러나 녹색 점 주변에 분포로는 빨간색 삼각형보다 파란색 사각형이 더 많은데, 이와 같은 이유로 녹색점을 파란색 사각형으로 분류하는 방식을 kNN 알고리즘이라고 한다.

  • kNN 알고리즘에서 k를 1로 설정하면 최근접 이웃 알고리즘이 된다. 그러므로 보통 k는 1보다 큰 값을 설정하며, k값을 어떻게 설정하느냐에 따라 분류 및 회귀 결과가 달라질 수 있다.
    • 최선의 k 값을 결정하는 것은 주어진 데이터에 의존적이며, 보통 k값이 커질수록 잡음 또는 이상치 데이터의 영향이 감소한다. 그러나 k값이 어느 정도 이상으로 커질 경우 오히려 분류 및 회귀 성능이 떨어질 수 있다.

KNearest 클래스 사용하기

  • OpenCV에서 k 최근접 이웃 알고리즘은 KNearest 클래스에 구현되어 있다.
    • (KNearest의 함수 설명 생략)
    • KNearest 객체는 기본적으로 분류를 위한 용도로 사용된다. 만일 KNearest 객체를 분류가 아닌 회귀에 적용하려면 KNearest::setIsClassifier() 멤버 함수에 false를 지정하여 호출하면 된다.
    • KNearest 객체를 생성하고 속성을 설정한 후에는 StatModel::train() 함수를 통해 학습을 진행할 수 있는데, KNearest 클래스의 경우에는 train() 함수에서 실제적인 학습이 진행되지는 않으며 단순히 훈련 데이터와 레이블 데이터를 KNearest 클래스 멤버 변수에 모두 저장하는 작업이 이루어진다.
  • KNearest 클래스에서 훈련 데이터를 학습한 후 테스트 데이터에 대한 예측을 수행할 때는 KNearest::findNearest() 멤버 함수를 사용한다.
    • 이는 StatModel::predict() 보다 KNearest::findNearest() 함수가 예측 결과와 관련된 정보를 더 많이 반환하기 때문이다.
    • KNearest::findNearest() 함수는 samples 행렬 각 행에 저장된 테스트 데이터와 가까운 k개의 훈련 데이터를 찾아 분류 또는 회귀 응답을 반환한다.
#include "opencv2/opencv.hpp"
#include <iostream>

using namespace cv;
using namespace cv::ml;
using namespace std;

Mat img;
Mat train, label;
Ptr<KNearest> knn;
int k_value = 1;

void on_k_changed(int, void*);
void addPoint(const Point& pt, int cls);
void trainAndDisplay();

int main(void)
{
img = Mat::zeros(Size(500, 500), CV_8UC3);
knn = KNearest::create();

namedWindow("knn");
createTrackbar("k", "knn", &k_value, 5, on_k_changed);

const int NUM = 30;
Mat rn(NUM, 2, CV_32SC1);

randn(rn, 0, 50);
for (int i = 0; i < NUM; i++)
addPoint(Point(rn.at<int>(i, 0) + 150, rn.at<int>(i, 1) + 150), 0);

randn(rn, 0, 50);
for (int i = 0; i < NUM; i++)
addPoint(Point(rn.at<int>(i, 0) + 350, rn.at<int>(i, 1) + 150), 1);

randn(rn, 0, 70);
for (int i = 0; i < NUM; i++)
addPoint(Point(rn.at<int>(i, 0) + 250, rn.at<int>(i, 1) + 400), 2);

trainAndDisplay();

waitKey();
return 0;
}

void on_k_changed(int, void*)
{
if (k_value < 1)
k_value = 1;

trainAndDisplay();
}

void addPoint(const Point& pt, int cls)
{
Mat new_sample = (Mat_<float>(1, 2) << pt.x, pt.y);
train.push_back(new_sample);

Mat new_label = (Mat_<int>(1, 1) << cls);
label.push_back(new_label);
}

void trainAndDisplay()
{
knn->train(train, ROW_SAMPLE, label);

for (int i = 0; i < img.rows; ++i)
{
for (int j = 0; j < img.cols; ++j)
{
Mat sample = (Mat_<float>(1, 2) << j, i);
Mat res;
knn->findNearest(sample, k_value, res);

int response = cvRound(res.at<float>(0, 0));
if (response == 0)
img.at<Vec3b>(i, j) = Vec3b(128, 128, 255);
else if (response == 1)
img.at<Vec3b>(i, j) = Vec3b(128, 255, 128);
else if (response == 2)
img.at<Vec3b>(i, j) = Vec3b(255, 128, 128);
}
}

for (int i = 0; i < train.rows; i++)
{
int x = cvRound(train.at<float>(i, 0));
int y = cvRound(train.at<float>(i, 1));
int l = label.at<int>(i, 0);

if (l == 0)
circle(img, Point(x, y), 5, Scalar(0, 0, 128), -1, LINE_AA);
else if (l == 1)
circle(img, Point(x, y), 5, Scalar(0, 128, 0), -1, LINE_AA);
else if (l == 2)
circle(img, Point(x, y), 5, Scalar(128, 0, 0), -1, LINE_AA);
}

imshow("knn", img);
}

kNN을 이용한 필기체 숫자 인식

  • 20 x 20 숫자 영상 픽셀값 자체를 kNN 알고리즘 입력으로 사용하는 예시
    • 5000개의 숫자 영상 데이터의 한 장의 숫자 영상은 20 x 20 픽셀 크기이고, 이 픽셀 값을 모두 일렬로 늘어 놓으면 1 x 400 크기의 행렬로 변환할 수 있다.
    • 즉 필기체 숫자 훈련 데이터 하나는 400개의 숫자 값으로 표현되고, 이는 400차원 공간에서의 한 점과 같다.
    • digits.png 영상에 있는 각각의 숫자 영상을 1 x 400 행렬로 바꾸고, 이 행렬을 모두 세로로 쌓으면 전체 숫자 영상 데이터를 표현하는 5000 x 400 크기의 행렬을 만들 수 있다. 그리고 이 행렬을 KNearest 클래스의 훈련 데이터로 전달한다.
    • kNN 알고리즘으로 필기체 숫자 영상을 학습시키려면 각 필기체 숫자 영상이 나타내는 숫자 값을 레이블 행렬로 함께 전달해야 한다. 이 레이블 행렬의 행 크기는 훈련 데이터 영상 개수와 같고, 열 크기는 1이된다.
    • 아래 그림에서 첫 행은 0, 그 다음 행은 1에 대한 데이터이므로 레이블 행렬도 첫 행의 원소는 0으로 설정하고 그 다음 해으이 원소는 1로 설정한다. 그렇게 모든 행의 원소를 설정한 후, KNearest 클래스의 레이블 데이터로 전달한다.

#include "opencv2/opencv.hpp"
#include <iostream>

using namespace cv;
using namespace cv::ml;
using namespace std;

Ptr<KNearest> train_knn();
void on_mouse(int event, int x, int y, int flags, void* userdata);

int main()
{
Ptr<KNearest> knn = train_knn();

if (knn.empty())
{
cerr << "Training failed!" << endl;
return -1;
}

Mat img = Mat::zeros(400, 400, CV_8U);

imshow("img", img);
setMouseCallback("img", on_mouse, (void*)&img);

while(true)
{
int c = waitKey(0);

if (c == 27)
{
break;
}
else if (c == ' ')
{
Mat img_resize, img_float, img_flatten, res;
resize(img, img_resize, Size(20, 20), 0, 0, INTER_AREA);
img_resize.convertTo(img_float, CV_32F);
img_flatten = img_float.reshape(1, 1);

knn->findNearest(img_flatten, 3, res);
cout << cvRound(res.at<float>(0, 0)) << endl;

img.setTo(0);
imshow("img", img);
}
}

return 0;
}

Ptr<KNearrest> train_knn()
{
Mat digits = imread("digits.png", IMREAD_GRAYSCALE);

if (digits.empty())
{
cerr << "Image load failed!" << endl;
return 0;
}

Mat train_images, train_labels;

for (int j = 0; j < 50; j++)
{
for (int i = 0; i < 100; i++)
{
Mat roi, roi_float, roi_flatten;
roi = digits(Rect(i*20, j*20, 20, 20));
roi.convertTo(roi_float, CV_32f);
roi_flatten = roi_float.reshape(1, 1);

train_images.push_back(roi_flatten);
train_labels.push_back(j / 5);
}
}

Ptr<KNearest> knn = KNearest::create();
knn->train(train_images, ROW_SAMPLE, train_labels);

return knn;
}

Point ptPrev(-1, -1);

void on_mouse(int event, int x, int y, int flags, void* userdata)
{
Mat img = *(Mat*)userdata;

if (event == EVENT_LBUTTONDOWN)
{
ptPrev = Point(x, y);
}
else if (event == EVENT_LBUTTONUP)
{
ptPrev = Point(-1, -1);
}
else if (event == EVENT_MOUSEMOVE && (flags & EVENT_FLAG_LBUTTON))
{
line(img, ptPrev, Point(x, y), Scalar::all(255), 40, LINE_AA, 0);
ptPrev = Point(x, y);

imshow("img", img);
}
}

서포트 벡터 머신

서프트 벡터 머신 알고리즘

  • 서포트 벡터 머신(SVM, Support, Vector Machine)은 기본적으로 두 개의 클래스로 구성된 데이터를 가장 여유 있게 분리하는 초평면(hyperplane)을 찾는 머신 러닝 알고리즘이다.
    • 초평면이란 두 클래스의 데이터를 분리하는 N차원 공간상의 평면을 의미한다. 예컨대 2차원 공간상의 점들을 분리하는 초평면은 단순한 직선 형태로 정의되며, 3차원 공간상의 점들을 분리하는 초평면은 3차원 공간에서의 평면의 방정식으로 표현할 수 있다.
    • SVM 알고리즘은 지도 학습의 일종으로 분류와 회귀에 사용될 수 있다.
  • 아래 그림은 SVM 알고리즘에 대한 예시이다.
    • 아래 그림은 파란색 사각형과 빨간색 삼각형으로 표시된 두 클래스의 점들의 분포를 나타내는데, 이 두 클래스 점들을 구분하기 위한 직선은 매우 다양하게 만들 수 있다.
    • 그림 (a)의 1, 2번 직선은 모두 두 종류의 점들을 잘 분리하지만, 1번 직선은 조금만 왼쪽이나 오른쪽으로 이동해도 분리에 실패하게 되고, 2번 직선도 오른쪽으로 조금만 이동하면 분리에 실패하게 된다.
    • 이는 1, 2번 직선이 모두 입력 점 데이터에너무 가까이 위치하고 있기 때문인데, 그림 (b)의 3번 직선은 두 클래스 점들 사이를 충분히 여유 있게 분할하고 있어서 그런 문제가 없다.
    • 이때 3번 직선에 해당하는 초평면과 가장 가까이 있는 빨간색 또는 파란색 점들과의 거리를 마진(margin)이라 하며, SVM은 이 마진을 최대로 만드는 초평면을 구하는 알고리즘이다.

  • SVM은 기본적으로 선형으로 분리 가능한 데이터에 적용할 수 있다.
    • 그러나 실생활에서 사용하는 데이터는 선형으로 분리되지 않는 경우가 많으며, 이러한 경우에도 SVM 알고리즘을 적용하기 위해 SVM에서는 커널 트릭(kernel trick)이라는 기법을 사용한다.
    • 커널 트릭이란 적절한 커널 함수를 이용하여 입력 데이터 특징 공간 차원을 늘리는 방식이다. 원본 데이터 차원에서는 선형으로 분리할 수 없었던 데이터를 커널 트릭으로 고차원 특징 공간으로 이동하면 선형으로 분리 가능한 형태로 바뀔 수 있다.
  • 데이터 특징 공간 차원을 증가시켜서 데이터를 선형 분리하는 예는 다음과 같다.
    • 2차원 좌표 평면 상의 점 집합 X = { (0, 0), (1, 1) }과 Y = { (1, 0), (0, 1) }이 있다고 가정하고, 이 두 클래스 점들을 아래 그림 처럼 각각 파란색과 빨간색 점으로 나타냈다.
    • 2차원 평면상에서 X, Y 두 클래스 점들을 분리할 수 있는 직선은 존재하지 않는데, 입력 점들의 좌표에 가상의 z축 좌표를 z_{i} = | x_{i} - y_{i} | 형태로 추가할 경우, X = { (0, 0, 0), (1, 1, 0) }과 Y = { (1, 0, 1), (0, 1, 1) } 형태로 3차원 공간상에서의 점 집합으로 바뀌게 된다.
    •  이렇게 차원 공간으로 변경된 X와 Y 점들을 아래 그림의 (b)처럼 그릴 수 있다. 그리고 이 두 클래스 점들은 z = 0.5 평면의 방정식을 이용하여 효과적으로 분리할 수 있다.
    • 2차원 평면에서 선형 분리할 수 없었던 X와 Y 데이터 집합이 가상의 차원을 추가함으로써 선형으로 분리될 수 있게 된 것이다.

  • SVM 알고리즘에서 사용할 수 있는 커널 함수의 종류는 아래 표와 같다.
    • 아래 표에서 가장 널리 사용되는 커널은 방사 기저 함수이며, 이 커널을 사용할 때는 \gamma   인자 값을 적절히 설정해야 한다. 
    • 만약 입력 데이터가 선형으로 분리 가능하다면 선형 커널을 사용하는 것이 가장 빠르게 동작한다.
SVM 커널 커널 함수
선형(linear) K(x_{i}, x_{j}) = x_{i}^{T}x_{j}
다항식(polynomial) K(x_{i}, x_{j}) = (\gamma x_{i}^{T}x_{j} + c_{0})^{degree}, \gamma > 0
방사 기저 함수(radial basis function) K(x_{i}, x_{j}) = exp(-\gamma \|x_{i}-x_{j}\|^{2}), \gamma > 0
시그모이드(sigmoid) K(x_{i}, x_{j}) = tanh(\gamma x_{i}^{T} x_{j} + c+{0})
지수 카이 제곱(exponential chi-square) K(x_{i}, x_{j}) = exp(-\gamma {(x_{i} - x_{j})^{2} \over x_{i} + x_{j}}), \gamma > 0
히스토그램 교차(histogram intersection) K(x_{i}, x_{j}) = min(x_{i}, x_{j})

SVM 클래스 사용하기

  • OpenCV에서 SVM 알고리즘은 SVM 클래스에 구현되어 있다. OpenCV에 구현된 SVM 클래스는 오픈소스 라이브러리인 LIBSVM을 기반으로 만들어졌다.
    • SVM 클래스는 기본적으로 SVM::Types::C_SVC 타입을 사용하도록 초기화되며 다른 타입을 사용하려면 SVM::setType() 함수를 이용하여 타입을 변경할 수 있다.
    • SVM::Types::C_SVC 타입을 사용하는 경우 SVM 알고리즘 내부에서 사용하는 C 파라미터 값을 적절하게 설정해야 하는데, C 값을 작게 설정하면 훈련 데이터 중에 잘못 분류된느 데이터가 있어도 최대 마진을 선택하고, C 값을 크게 설정하면 마진이 작아지더라도 잘못 분류되는 데이터가 적어지도록 분류한다.
    • 만약 훈련 샘플 데이터에 잡음 또는 이상치 데이터가 많이 포함된 경우에는 C 파라미터 값을 크게 설정하는 것이 유리하다.
SVM::Types 설명 파라미터
C_SVC C-서포트 벡터 분류. 일반적인 n-클래스 분류 문제에서 사용된다. C
NU_SVC v-서포트 벡터 분류. C_SCV와 비슷하지만 Nu 값 범위가 0-1 사이로 정규화 되어 있다. Nu
ONE_CLASS 1-분류 서포트 벡터 머신. 데이터 분포 측정에 사용된다. C, Nu
EPS_SVR \epsilon -서포트 벡터 회귀 P, C
NU_SVR v-서포트 벡터 회귀 Nu, C
  • SVM 타입 설정 후에 SVM 알고리즘에서 사용할 커널 함수를 지정해야 한다. 함수 지정은 SVM::setKernel() 함수를 이용하면 된다.
SVM::KernelTYpes 설명 파라미터
LINEAR 선형 커널  
POLY 다항식 커널 Degree, Gamma, Coef0
RBF 방사 기저 함수 커널 Gamma
SIGMOID 시그모이드 커널 Gamma, Coef0
CHI2 지수 카이 제곱 커널 Gamma
INTER 히스토그램 교차 커널  
  • SVM 타입과 커널 함수 종류를 설정한 후에는 각각의 타입과 커널 함수 정의에 필요한 파라미터를 설정해야 한다.
    • SVM 클래스에서 설정할 수 있는 파라미터는 C, Nu, P, Degree, Gamma, Coef0 등이 있으며, 이들 파라미터는 차례대로 1, 0, 0, 0, 1, 0으로 초기화 된다.
    • 각각의 파라미터는 파라미터 이름에 해당하는 setXXX()와 getXXX(0 함수를 이용하여 값을 설정하거나 읽어올 수 있다.
  • SVM 객체를 생성하고 타입과 커널 함수, 파라미터를 설정한 후에는 StatModel::train() 함수를 이용하여 학습을 시킬 수 있다.
    • 그러나 SVM에서 사용하는 파라미터를 적절하게 설정하지 않으면 학습이 제대로 되지 않는데, OpenCV에서는 각각의 파라미터에 대해 설정 가능한 값을 적용해 보고 그중 가장 성능이 좋은 파라미터를 자동으로 찾아 학습하는 SVM::trainAuto() 함수를 제공한다.
    • 다만 SVM::trainAuto() 함수는 매우 느리기 때문에 한 번 학습이 완료된 후 선택된 파라미터를 저장했다가 재사용하는 편이 좋다.
  • SVM 학습이 완료되었다면 StatModel::predict()를 통해 테스트 데이터에 대한 예측을 수행할 수 있다.
#include "opencv2/opencv.hpp"
#include <iostream>

using namespace cv;
using namespace cv::ml;
using namespace std;

int main(void)
{
Mat train = Mat_<float>({8, 2}, {150, 200, 200, 250, 100, 250, 150, 300, 350, 100, 400, 200, 400, 300, 350, 400});
Mat label = Mat_<int>({8, 1}, {0, 0, 0, 0, 1, 1, 1, 1});

Ptr<SVM> svm = SVM::create();
svm->setType(SVM::Types::C_SVC);
svm->setKernel(SVM::KernelTypes::RBF);
svm->trainAuto(train, ROW_SAMPLE, label);

Mat img = Mat::zeros(Size(500, 500), CV_8UC3);

for (int j = 0; j < img.rows; j++)
{
for (int i = 0; i < img.cols; i++)
{
Mat test = Mat_<float>({1, 2}, {(float)i, (float)j});
int res = cvRound(svm->predict(test));

if (res == 0)
img.at<Vec3b>(j, i) = Vec3b(128, 128, 255);
else
img.at<Vec3b>(j, i) = Vec3b(128, 255, 128);
}
}

for (int i = 0; i < train.rows; i++)
{
int x = cvRound(train.at<float>(i, 0));
int y = cvRound(train.at<float>(i, 1));
int l = label.at<int>(i, 0);

if (l == 0)
cicle(img, Point(x, y), 5, Scalar(0, 0, 128), -1, LINE_AA);
else
cicle(img, Point(x, y), 5, Scalar(0, 128, 0), -1, LINE_AA);
}

imshow("svm", img);

waitKey();
return 0;
}

HOG & SVM 필기체 숫자 인식

  • kNN으로 했던 필기체 인식의 SVM 버전
    • 각 숫자 영상에서 HOG 특징 벡터를 추출한 후 SVM 알고리즘 입력 데이터로 사용한다.
    • HOG 특징 벡터 추출을 위해 HOGDescriptor 클래스를 사용한다.
#include "opencv2/opencv.hpp"
#include <iostream>

using namespace cv;
using namespace cv::ml;
using namespace std;

Ptr<SVM> train_hog_svm(const HOGDescriptor& hog);
void on_mouse(int event, int x, int y, int flags, void* userdata);

int main()
{
HOGDescriptor hog(Size(20, 20), Size(10, 10), Size(5, 5), Size(5, 5), 9);

Ptr<SVM> svm = train_hog_svm(hog);

if (svm.empty())
{
cerr << "Training failed!" << endl;
return -1;
}

Mat img = Mat::zeros(400, 400, CV_8U);

imshow("img", img);
setMoustCallback("img", on_mouse, (void*)&img);

 while(true)
{
int c = waitKey(0);

if (c == 27)
{
break;
}
else if (c == ' ')
{
Mat img_resize;
resize(img, img_resize, Size(20, 20), 0, 0, INTER_AREA);

vector<float> desc;
hog.compute(img_resize, desc);

Mat desc_mat(desc);
int res = cvRound(svm->predict(desc_mat.t()));
cout << res << endl;

img.setTo(0);
imshow("img", img);
}
}

return 0;
}

Ptr<SVM> train_hog_svm(const HOGDescriptor& hog)
{
Mat digits = imread("digits.png", IMREAD_GRAYSCALE);

if (digits.empty())
{
cerr << "Image load failed!" << endl;
return 0;
}

Mat train_hog, train_labels;

for (int j = 0; j < 50; j++)
{
for (int i = 0; i < 100; i++)
{
Mat roi = digits(Rect(i*20, j*20, 20, 20));

vector<float> desc;
hog.compute(roi, desc);

Mat desc_mat(desc);
train_hog.push_back(desc_mat.t());
train_labels.push_back(j / 5);
}
}

// 아래 상수값은 SVM::trainAuto()를 통해 구한 값이다.
Ptr<SVM> svm = SVM::create();
svm->setType(SVM::Types::C_SVC);
svm->setKernel(SVM::KernelTypes::RBF);
svm->setC(2.5);
svm->setGamma(0.50625);
svm->train(train_hog, ROW_SAMPLE, train_labels);

return svm;
}

Point ptPrev(-1, -1);

void on_mouse(int event, int x, int y, int flags, void* userdata)
{
Mat img = *(Mat*)userdata;

if (event == EVENT_LBUTTONDOWN)
{
ptPrev = Point(x, y);
}
else if (event == EVENT_LBUTTONUP)
{
ptPrev = Point(-1, -1);
}
else if (event == EVENT_MOUSEMOVE && (flags & EVENT_FLAG_LBUTTON))
{
line(img, ptPrev, Point(x, y), Scalar::all(255), 40, LINE_AA, 0);
ptPrev = Point(x, y);

imshow("img", img);
}
}

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 지역 특징점 검출과 매칭

코너 검출

해리스 코너 검출 방법

  • 영상에서 특징(feature)란 영상으로부터 추출할 수 있는 유용한 정보를 의미하며, 평균 밝기, 히스토그램, 에지, 직선 성분, 코너 등이 특징이 될 수 있다.
    • 영상의 특징 중에서 에지, 직선, 성분, 코너처럼 영상 전체가 아닌 일부 영역에서 추출할 수 있는 특징을 지역 특징(local feature)이라고 한다.
    • 영상의 지역 특징 중 코너(corner)는 에지의 방향이 급격하게 변하는 부분으로서 삼각형의 꼭지점이나 연필 심처럼 뾰족하게 튀어나와 있는 부분이 코너가 될 수 있다.
    • 코너는 에지나 직선 성분에 비해 분별력이 높고 대체로 영상 전 영역에 골고루 분포하기 때문에 영상을 분석하는데 유용한 지역 특지응로 사용된다.
    • 코너처럼 한 점의 형태로 표현할 수 있는 특징을 특징점(feature point)라고 하며, 특징점은 키포인트(keypoint) 또는 관심점(interest point)라고 부르기도 한다.
  • 아래 그림에서 A 부분은 내부 픽셀값 변화가 크지 않은 평탄한 영역이며, 원본 영상에서 하늘 여역 전체는 A와 비슷한 픽셀값 분포를 갖는다.
    • B 부분은 하늘과 바다가 만나는 수평선 부근으로 정확한 x 좌표는 가늠하기 어렵다. 
    • C 부분은 건물이 뾰족하게 튀어나와 있는 부분으로 원본 영상 오른쪽 산등성이에서 유일한 위치를 찾을 수 있다.
    • 이렇듯 코너는 에지나 평탄한 영역에 비해 변별력이 높아서 그 위치를 파악하기 쉽다.

  • 영상에서 코너를 찾는 연구는 1970년대 후반부터 활발하게 진행되었는데, 1988년 해리스(C. Harris)가 개발한 코너 검출 방법은 코너 점 구분을 위한 기본적인 아이디어를 수학적으로 잘 정의하였다는 점에서 큰 의미가 있다.
    • 해리스는 영상의 특정 위치 (x, y) 에서 \Delta x \Delta y 만큼 떨어진 픽셀과의 밝기 차이를 다음 수식으로 표현하였다.

E(\Delta x, \Delta y) = \sum_{x, y} w(x, y) [I(x + \Delta x, y + \Delta y) - I(x, y)]^{2}

  • 위 수식에서 w(x, y) 는 균일한 값을 갖는 사각형 윈도우 또는 가우시안 형태의 가중치를 갖는 윈도우이다.
    • 만약 E(\Delta x, \Delta y) 함수가 모든 방향으로 값이 크게 나타난다면 점 (x, y) 는 코너라고 간주할 수 있다.
    • 해리스는 E(\Delta x, \Delta y) 가 모든 방향으로 그 값이 크게 나타나는지를 검사하기 위해 테일러 급수(Taylor series), 고윳값 분석(eigenvalue analysis) 등의 수학적 기법을 적용하여 코너 응답 함수 R을 유도하였다.

R = Det(M) - k \cdot Tr(M)^{2}

  • 위 수식에서 Det()는 행렬식(determinant)을, Tr()은 대각합(trace)을 의미하고, 행렬 M은 다음과 같이 정의된다.

M = \sum_{x, y} w(x, y) \left[ \begin{array}{rr} I_{x} I_{x} & I_{x} I_{y} \\ I_{x} I_{y} & I_{y} I_{y} \end{array} \right]

  • 위 수식에서 I_{x} I_{y} s는 입력 영상 I 를 각각 x축 방향과 y축 방향으로 편미분한 결과이다.
    • 코너 응답 함수 정의에서 상수 k는 보통 0.04~0.06 사이의 값을 사용한다.
  • 해리스에 의해 정의된 코너 응답 함수 R은 입력 영상 각각의 픽셀에서 정의되는 실수 값이며, 이 값을 분석하여 코너, 에지, 평탄한 영역을 판별할 수 있다. 
    • 만약 R이 0보다 충분히 큰 양수이면 코너 점이라고 간주한다.
    • 반면 R이 0에 가까운 실수이면, 평탄한 영역이고, 0보다 작은 음수이면 에지라고 판별한다.
  • OpenCV는 해리스 코너 응답 함수 값을 계산하는 cornerHarris() 함수를 제공한다.
    • cornerHarris() 함수는 입력 영상 src의 모든 픽셀 위치에서 해리스 코너 응답 함수 값을 계산하고 그 결과를 dst 행렬로 반환한다.
    • dst 행렬의 모든 원소는 float 자료형을 사용하며, 이 값이 사용자가 지정한 임계값보다 크면 코너 점으로 판단할 수 있다.
    • 이때 하나의 코너 위치에 사용자가 지정 임계값보다 큰 픽셀이 여러 개 발생할 수 있으므로 간단한 비최대 억제를 수행하여 지역 최댓값 위치만 코너로 판별하는 것이 좋다.
void corner_harris()
{
Mat src = imread("building.jpg", IMREAD_GRAYSCALE);

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Mat harris;
cornerHarris(src, harris, 3, 3, 0.04);

Mat harris_norm;
normalize(harris, harris_norm, 0, 255, NORM_MINMAX, CV_8U);

Mat dst;
cvtColor(src, dst, COLOR_GRAY2BGR);

for (int j = 1; j < harris.rows - 1; j++)
{
for (int i = 1; i < harris.cols - 1; i++)
{
if (harris_norm.at<uchar>(j, i) > 120)
{
if (harris.at<float>(j, i) > harris.at<float>(j-1, i) &&
harris.at<float>(j, i) > harris.at<float>(j+1, i) &&
harris.at<float>(j, i) > harris.at<float>(j, i-1) &&
harris.at<float>(j, i) > harris.at<float>(j, i+1))
{
circle(dst, Point(i, j), 5, Scalar(0, 0, 255), 2);
}
}
}
}

imshow("src", src);
imshow("harris_norm", harris_norm);
imshow("dst", dst);

waitKey(0);
destroyAllWindows();
}

Fast 코너 검출 방법

  • 해리스 코너 검출 방법은 영상의 코너 특성을 수학적으로 잘 정의하고, 복잡한 수식을 잘 전개하여 수치적으로 코너를 검출하였다는데 의미가 있다. 이후로도 비슷한 컨셉을 발전시켜 추적에 적합한 특징(Good Features to Track)이라는 이름의 코너 검출 방법도 제안되었고, OpenCV에도 그 기능이 구현되어 있다.
    • 그러니 이러한 코너 검출 방법들은 복잡한 연산을 필요로 하기 때문에 연산 속도가 느리다는 단점이 있다.
    • 이러한 코너 검출 방법과 달리 2006년에 발표된 FAST 코너 검출 방법은 단순한 픽셀값 비교 방법을 통해 코너를 검출한다. FAST는 Features from Accelerated Segment Test의 약자이다.
  • FAST 방법은 영상의 모든 픽셀에서 픽셀을 둘러싸고 있는 16개의 주변 픽셀과 밝기를 비교하여 코너 여부를 판별한다.
    • 아개 그림에서 점 p가 코너인지 판별하기 위해 점 p 주변 1번부터 16번 픽셀과의 밝기를 비교한다.
    • 만약 주변 16개의 픽셀 중에서 점 p 보다 충분히 밝거나 충분히 어두운 픽셀이 9개 이상 연속으로 존재하면 코너로 정의한다.

  • 점 P에서의 밝기를 I_{p} 라고 할 때, 주변 16개의 픽셀 중에서 그 값이 I_{p} + t 보다 큰 픽셀이 9개 이상 연속으로 나타나면 점 p는 어두운 영역이 뾰족하게 돌출되어 있는 코너이다.
    • 반면 주변 16개의 픽셀 중에서 그 값이 I_{p} - t 보다 작은 픽셀이 9개 이상 연속으로 나타나면 점 p는 밝은 영역이 돌출되어 있는 코너라고 간주한다.
    • 여기서 t는 충분히 밝거나 어두운 정도를 조절하기 위한 임계값이다.
  • FAST 방법은 특정 코너 점 주변 픽셀들도 함께 코너로 검출하는 경우가 많기 때문에 주변 코너 픽셀 중에서 가장 코너에 적합한 픽셀을 선택하는 비최대 억제 작업을 추가적으로 수행하는 것이 좋다.
    • FAST 방법에서는 코너 점 주변 16개 점과 픽셀 값 차이 합을 코너 점수로 정의하고, 인접한 코너 중에서 코너 점수가 가장 큰 코너만 최종 코너로 선택한다.
  • OpenCV는 FAST 코너 검출 방법을 구현한 FAST() 함수를 제공한다.
    • FAST() 함수의 입력 영상으로는 CV_8UC1 타입의 그레이스케일 영상만 사용할 수 있다.
    • FAST() 함수의 두 번째 인자 keypoints는 KeyPoint 클래스 객체의 벡터로 저장한다.
void corner_fast()
{
Mat src = imread("building.jpg", IMREAD_GRAYSCALE);

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

vector<KeyPoint> keyPoints;
FAST(src, keyPoints, 60, true);

Mat dst;
cvtColor(src, dst, COLOR_GRAY2BGR);

for (KeyPoint kp : keyPoints)
{
Point pt(cvRount(kp.pt.x), cvRound(kp.pt.y));
circle(dst, pt, 5, Scalar(0, 0, 255), 2);
}

imshow("src", src);
imshow("dst", dst);

waitKey(0);
destroyAllWindows();
}
  • Note)
    • cornerHarris() 함수와 FAST(0 함수의 동작 시간을 비교해보면 FAST 코너 검출 방법이 대략 20배 이상 빠르게 동작한다.

크기 불변 특징점 검출과 기술

크기 불변 특징점 알고리즘

  • 코너는 영상이 회전되어도 여전히 코너로 검출된다. 그러므로 코너는 회전 불변 특징점이라 할 수 있다. 그러나 영상의 크기가 변경될 경우 코너는 더 이상 코너로 검출되지 않을 수 있다.
    • 아래 그림은 객체의 크기 변화에 따른 코너의 형태 변화를 보여준다. 왼쪽 그림에서 파란색 사각형 내부는 에지가 급격하게 휘어지는 코너처럼 보이지만 영상이 확대되어 오른쪽 그림처럼 변경되면 같은 크기의 사각형 안에서 코너보다는 에지에 가까운 형태로 관측되는 것을 볼 수 있다.

  • 따라서 크기가 다른 두 객체 영상에서 단순한 코너 점을 이용하여 서로 같은 위치를 찾는 것에는 한계가 있다. 그래서 많은 사람들이 크기가 다른 영상에서도 지속적으로 검출될 수 있는 크기 불변 특징에 대해 연구하였고, 그중 가장 대표적인 알고리즘이 SIFT이다.
    • SIFT는 크기 불변 특징 변환(Scale Invariant Feature Transform)의 약자이며, 2004년 캐나다의 브리티시 컬럼비아 대학교의 로우(D. Lowe) 교수가 발표한 논문에 소개된 방법이다.
  • SIFT 알고리즘은 영상의 크기 변화에 무관하게 특징점을 추출하기 위해 입력 영상으로부터 스케일 스페이스(scale space)를 구성한다.
    • 스케일 스페이스는 영상에 다양한 표준 편차를 이용한 가우시안 블러링을 적용하여 구성한 영상 집합을 의미한다.
    • 레나 영상에 대해 스케일 스페이스를 구성한 예가 아래 그림과 같다.
    • 그림의 맨 윗줄에 나타난 여섯 개의 블러링된 영상이 스케일 스페이스를 구성한 결과이며, 이렇게 구성한 영상 집합을 옥타브(octave)라고 부른다.
    • 이후 입력 영상의 크기를 가로, 세로 반으로 줄여 가면서 여러 옥타브를 구성한다.

  • SIFT 알고리즘에서 크기에 불변한 특징점을 검출할 때는 인접한 가우시안 블러링 영상끼리의 차영상을 사용하며, 이를 DoG(Difference of Gaussian) 영상이라고 한다.
    • 위 그림 아래쪽에 나열한 영상이 레나 영사으로부터 구한 DoG 영상이다.
    • SIFT 알고리즘은 DoG 영상 집합에서 인접한 DoG 영상을 고려한 지역 극값 위치를 특징점으로 사용하며, 이후 에지 성분이 강하거나 명암비가 낮은 지점은 특징점에서 제외한다.
  • SIFT 알고리즘은 특징점을 검출하는 기능뿐만 아니라 특징점 주변의 픽셀 값을 이용한 기술자(descriptor) 계산 방법도 포함한다.
    • 특징점 기술자는 특징점 주변 영상의 특성을 여러 개의 실수 값으로 표현한 것을 의미하며, 특징 벡터(feature vector)라고도 한다.
    • 서로 같은 특징점에서 추출된 기술자는 실수 값 구성이 서로 일치해야 한다.
    • SIFT는 기본적으로 특징점 부근의 부분 영상으로부터 그래디언트 방향 히스토그램을 추출하여 기술자로 사용한다. 특징점 근방으로부터 특징점의 주된 방향 성분을 계산하고, 이 방향만큼 회전한 부분 영상으로부터 128개의 빈으로 구성된 그래디언트 방향 히스토그램을 계산한다.
    • 각각의 빈 값은 float 자료형을 사용하며, 하나의 SIFT 특징점은 512바이트 크기의 기술자료 표현된다.
  • SIFT 알고리즘은 영상의 크기, 회전 등의 변환 뿐만 아니라 촬영 시점 변환에도 충분히 강인하게 동작하며, 잡음의 영향과 조명 변화가 있어도 특징점을 반복적으로 잘 찾아낸다.
    • SIFT 알고리즘은 다양한 컴퓨터 비전 분야에서 적용되었고, 특히 객체 인식, 파노라마 영상 이어 붙이기 3차원 장면 인식 등의 분야에서 효과적으로 사용되었다.
  • SIFT 알고리즘이 발표된 이후, 많은 사람들이 SIFT 알고리즘의 속도와 성능을 개선한 알고리즘을 발표했다.
    • 2008년에 발표된 SURF(Speed-Up Robust Feature) 알고리즘은 SIFT에서 사용한 DoG 영상을 단순한 이진 패턴으로 근사화하여 속도를 향상시켰다.
    • 2012년에 발표된 KAZE 알고리즘은 가우시안 함수 대신 비등방성 확산 필터(nonlinear diffusion filter)를 이용하여 비선형 스케일 스페이스를 구축하여 특징점을 검출한다.
    • KAZE 알고리즘은 객체의 윤곽을 잘 보전함으로써 블러링, 크기 및 회전 변환, 잡음 등의 영향으로 변형된 영상에서 같은 특징점을 반복적으로 찾아내는 성능이 뛰어나다.
  • 그러나 SIFT, SURF, KAZE 방법은 스케일 스페이스를 구성하는 등의 복잡한 연산을 수행해야 하기 때문에 실시간 응용에서 사용하기 어렵다는 단점이 있다.
    • 또한 이들 특징점 알고리즘에 의해 만들어지는 기술자는 128개 또는 64개의 실수 값으로 구성되어 있어서 메모리 사용량이 많고, 특징점 사이의 거리 게산도 오래 걸릴 수 있다는 단점이 있다.
    • 그래서 2010년 전후 특징점 검출이 매우 빠르고 이진수로 구성된 기술자를 사용하는 알고리즘이 발표되기 시작했고, 그중 2011년에 발표된 ORB(Oriented FAST and Rotated BRIEF) 알고리즘은 당시 OpenCV를 관리하던 연구소에서 개발한 방법으로서 SIFT와 SURF를 대체하기에 좋은 알고리즘이다.
  • ORB 알고리즘은 기본적으로 FAST 코너 검출 방법을 이용하여 특징점을 추출한다. 다만 기본적인 FAST 알고리즘은 영상의 크기 변화에 취약하기 때문에 ORB 알고리즘은 입력 영상의 크기를 점진적으로 축소한 피라미드 영상을 추구하여 특징점을 추출한다.
    • 그리고 각 특징점에 주된 방향 성분을 계산하고, 방향을 고려한 BRIEF 알고리즘으로 이진 기술자를 계산한다.
  • ORB에서 사용한 BRIEF(Binary Robust Independent Elementary Feature)는 순수하게 특징점 기술자만을 생성하는 알고리즘으로 특징점 주변의 픽셀 쌍을 미리 정하고 해당 픽셀 값 크기를 비교하여 0 또는 1로 특징을 기술한다.
    • 두 점 x, y 에서의 픽셀 값 크기 비교 테스트 \tau  는 다음과 같이 정의한다.

\tau(x, y) = \begin{cases} 1 & I(x) < I(y) \\ 0 & else \end{cases}

  • 예컨대 아래 그림과 같이 특징점 p 주변에 a, b, c 점을 미리 정의하고, \tau(a, b), \tau(b, c), \tau(c, a)  를 구하면 이진수 110_{2} 를 얻을 수 있다.
    • 이진수 110_{2} 는 b 점이 a보다 밝고, c 점이 b 보다 밝고, a가 c 보다 어둡다는 정보를 표현한다.
    • 이처럼 특징점 주변 정보를 이진수 형태로 표현하는 기술자를 이진 기술자(binary descriptor)라고 한다.

  • ORB 알고리즘은 FAST 기반의 방법으로 특징점을 구한 후, 각 특징점에서 픽셀 밝기 값 분포를 고려한 코너 방향 성분을 계산한다.
    • 그리고 이 방향 성분을 이용하여 BRIEF 계산에 필요한 점들의 위치를 보정함으로써 회전에 불변한 BRIEF 기술자를 계산한다.
    • ORB 알고리즘에서는 기본적으로 256개의 크기 비교 픽셀 쌍을 사용하여 이진 기술자를 구성하며, 결과적으로 하나의 특징점은 256비트로 표현할 수 있다.
    • SIFT와 SURF 기술자가 512, 256 바이트를 사용하는 것에 비해 ORB는 32바이트의 크기로 특징점을 기술할 수 있어서 효율적이다.
  • 이진 기술자로 표현된 특징점 사이의 거리 계산은 주로 해밍 거리(Hamming distance) 방법을 사용한다.
    • 해밍 거리는 이진수로 표현된 두 기술자에서 서로 값이 다른 비트의 개수를 세는 방식으로 계산한다.
    • 해밍 거리 계산은 두 기술자의 비트 단위 배타적 논리합(XOR) 연산 후, 비트 값이 1인 개수를 세는 방식으로 빠르게 계산할 수 있다.
    • ORB 외에도 BRISK, AKAZE, FREAK 등의 이진 기술자를 사용하는 특징점 알고리즘이 있다.

OpenCV 특징점 검출과 기술

  • OpenCV에서 특징점 정보를 저장할 때 사용하는 클래스는 KeyPoint이며, 이 클래스는 특징점 좌표뿐만 아니라 특징점 검추 시 고려한 주변 영역의 크기, 주된 방향, 옥타브 정보 등을 변수로 갖고 있다.
  • OpenCV에서 특징점 관련 클래스는 모두 Feature2D 클래스를 상속 받아 만들어진다.
    • Feature2D 클래스는 detect(), compute(), detectAndCompute()라는 이름의 가상 멤버 함수를 갖고 있고, 이 클래스를 상속 받은 각각의 특징점 알고리즘 구현 클래스는 이들 멤버 함수의 기능을 실제로 구현하도록 설계되어 있다.
    • detect() 함수는 영상에서 키포인트를 검출하고, compute() 함수는 검출된 키포인트를 표현하는 기술자를 생성한다. detectAndCompute() 함수는 그 둘을 한번에 수행한다.
  • (이하 클래스와 함수 설명 생략)
void detect_keypoints()
{
Mat src = imread("box_in_scene.png", IMREAD_GRAYSCALE);

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Ptr<Feature2D> feature = ORB::create();

vector<KeyPoint> keyPoints;
feature->detect(src, keypoints);

Mat desc;
feature->compute(src, keypoints, desc);

cout << "keypoints.size(): " << keyponts.size() << endl;
cout << "desc.size(): " << desc.size() << endl;

Mat dst;
drawKeypoints(src, keypoints, dst, Scalar::all(-1), DrawMatchesFlags::DRAW_RICH_KEYPOINTS);

imshow("src", src);
imshow("dst", dst);

waitKey();
destroyAllWindows();
}

특징점 매칭

OpenCV 특징점 매칭

  • 특징점 매칭이란 두 영상에서 추출한 특징점 기술자를 비교하여 서로 비슷한 특징점을 찾는 작업을 의미한다. 특히 크기 불변 특징점으로부터 구한 기술자를 매칭하면 크기와 회전에 강인한 영상 매칭을 수행할 수 있다.
  • OpenCV에서 특징점 매칭 정보를 저장할 때 DMatch 라는 클래스를 사용한다.
    • DMatch 클래스는 한 장의 영상에서 추출한 특징점과 다른 한 장의 영상 또는 여러 영상에서 추출한 특징점 사이의 매칭 정보를 표현할 수 있다.
    • DMatch 클래스에서 distance 멤버 변수는 두 키포인트 기술자가 얼마나 차이가 나는지를 나타내는 매칭 척도의 역할을 한다. 두 특징점이 서로 유사하면 distance 값이 0에 가깝고, 서로 다른 특징점이면 distance가 크게 나타난다.
    • distance 계산 방식은 다차원 벡터의 유클리드 거리로 주로 계산하며, 다만 이진 기술자끼리 비교하는 경우에는 해밍 거리를 사용한다.
    • DMatch 클래스 객체는 보통 사용자가 직접 생성하지 않고, 특징점 매칭 알고리즘 내부에서 생성해서 사용자에게 반환한다.
  • OpenCV의 특징점 매칭 클래스는 DescriptorMatcher 클래스를 상속받아 만들어지는데, 이 클래스는 match(), knnMatch(), radiusMatch() 등의 가상 멤버 함수를 갖고 있는 추상 클래스이며 BFMatcher, FlannBasedMatcher 클래스는 이들 멤버 함수 기능을 구현하도록 설계되어 있다.
    • match() 함수는 가장 비슷한 기술자 쌍을 하나 찾고, knnMatch() 함수는 비슷한 기술자 쌍 k개를 찾는다. radiusMatch() 함수는 지정한 거리 반경 안에 있는 기술자 쌍을 모두 찾아 반환한다.
  • BFMatcher 클래스는 전수 조사(Brute-Force) 매칭을 수행한다. BFMatcher 클래스는 질의 기술자 집합에 있는 모든 기술자와 훈련 기술자 집합에 있는 모든 기술자 사이의 거리를 계산하고 이중 가장 거리가 작은 기술자를 찾아 매칭하는 방식이다.
    • BFMatcher 클래스의 매칭 결정 방법은 매우 직관적이지만, 특징점 개수가 늘어날수록 거리 계산 횟수가 급격하게 늘어날 수 있다는 단점이 있다.
    • 이러한 경우에는 FlannBasedMatcher 클래스를 사용하는 것이 효율적이다.
  • Flann(Fast Library approximate nearest neighbors)는 근사화된 최근방 이웃(ANN, Approximate Nearest Neighbors) 알고리즘을 빠르게 구현한 라이브러리이다.
    • FlannBasedMatcher 클래스는 Flann 라이브러리를 이용하여 빠르게 매칭을 수행한다.
    • FlannBasedMatcher 클래스는 근사화된 거리 계산 방법을 사용하므로 가장 거리가 작은 특징점을 찾지 못할 수 있지만, 매우 빠르게 동ㅈ가한다.
    • 다만 FlannBasedMatcher  클래스는 기본적으로 L2 노름 거리 측정 방식을 사용하므로 해밍 거리를 사용하는 이진 기술자에 대해서는 사용할 수 없다.
  • (클래스와 함수 설명 생략)
void keypoint_matching()
{
Mat src1 = imread("box.png", IMREAD_GRAYSCALE);
Mat src2 = imread("box_in_scene.png", IMREAD_GRAYSCALE);

if (src1.empty() || src2.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Ptr<Feature2D> feature = ORB::create();

vector<KeyPoint> keypoints1, keypoints2;
Mat desc1, desc2;
feature->detectAndCompute(src1, Mat(), keypoints1, desc1);
feature->detectAndCompute(src2, Mat(), keypoints2, desc2);

Ptr<DescriptorMatcher> matcher = BFMatcher::create(NORM_HAMMING);

vector<DMatch> matches;
matcher->match(desc1, desc2, matches);

Mat dst;
drawMatches(src1, keypoints1, src2, keypoints2, matches, dst);

imshow("dst", dst);

waitKey();
destroyAllWindows();
}

  • DMatch 클래스는 기술자 사이의 거리를 표현하는 distance를 멤버 변수로 갖고 있다. 그러므로 distance 값이 너무 큰 매칭 결과는 무시하고 distance 값이 작은 결과만 사용하는 것이 좋다.
    • DMatch 클래스는 부등호 연산자에 대해 재정의가 되어 있고, 이 연산자 재정의에서는 distance 멤버 변수 크기를 비교하기 때문에 DMatch 객체를 std::sort() 함수로 정렬하면 자동으로 distance 값을 기준으로 정렬된다.
void good_matching()
{
Mat src1 = imread("box.png", IMREAD_GRAYSCALE);
Mat src2 = imread("box_in_scene.png", IMREAD_GRAYSCALE);

if (src1.empty() || src2.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Ptr<Feature2D> feature = ORB::create();

vector<KeyPoint> keypoints1, keypoints2;
Mat desc1, desc2;
feature->detectAndCompute(src1, Mat(), keypoints1, desc1);
feature->detectAndCompute(src2, Mat(), keypoints2, desc2);

Ptr<DescriptorMatcher> matcher = BFMatcher::create(NORM_HAMMING);

vector<DMatch> matches;
matcher->match(desc1, desc2, matches);

std::sort(matches.begin(), matches.end());
// match 된 것 중에 상위 50개만 선별
vector<DMatch> good_matches(matches.begin(), matches.begin() + 50);

Mat dst;
drawMatches(src1, keypoints1, src2, keypoints2, matches, dst, Scalar::all(-1), Scalar::all(-1), vector<char>(), DrawmatchesFlags::NOT_DRAW_SINGLE_POINTS);

imshow("dst", dst);

waitKey();
destroyAllWindows();
}

호모그래피와 영상 매칭

  • 호모그래피란 3차원 공간사으이 평면을 서로 다른 시점에서 바라봤을 때 획득되는 영상 사이의 관계를 나타내는 용어이다.
    • 호모그래피는 수학적으로 하나의 평면을 다른 평면으로 투시 변환(perspective transform) 하는 것과 같은 관계에 있다.
    • 아래 그림은 3차원 공간에서 평면과 획득된 영상과의 호모그래피 관계를 보여준다.
    • 아래 그림은 바닥에 놓인 평면 P를 v_{1} 시점에서 바라본 영상 I_{1} v_{2} 시점에서 바라본 영상 I_{2} 사이의 관계를 호모그래피 H_{12} 로 표현하였다.
    • 또한 영상 I_{1} 과 평면 P 사이의 관계 또는 영상 I_{2} 와 평면 P 사이의 관계도 각각 호모그래피 H_{1} H_{2} 형태로 표현할 수 있다.

  • 실제적인 연산 관점에서 호모그래피는 투시 변환과 같기 때문에 호모그래피는 3 x 3 실수 행렬로 표현할 수 있다.
    • 또한 투시 변환을 구할 때와 마찬가지로 4개의 대응되는 점의 좌표 이동 정보가 있으면 호모그래피 행렬을 구할 수 있다.
    • 그러나 특징점 매칭 정보로부터 호모그래피를 구하는 경우에는 서로 대응되는 점 개수가 4개보다 훨씬 많기 때문에, 이러한 경우에는 투시 변환시 에러가 최소가 되는 형태의 호모그래피 행렬을 구해야 한다.
  • OpenCV에서는 두 영상 평면에서 추출된 특징점 매칭 정보로부터 호모그래피를 계산할 때 사용할 수 있는 findHomography() 함수를 제공한다.
    • findHomography() 함수는 두 평면 위에 있는 점들을 투영 변환하는 3 x 3 호모그래피 행렬을 반환한다. 원본 평면 상의 점 좌표를 (x_{i}, y_{i}) 로 표현하고 목표 평면상의 점 좌표를 (x_{i}', y_{i}') 로 표현할 경우 호모그래피 H는 다음 수식을 최소화하는 형태의 행렬이 된다.

\sum_{i} (x_{i}' - {h_{11}x_{i} + h_{12}y_{i} + h_{13} \over h_{31}x_{i} + h_{32}y_{i} + h_{33}})^{2} - (y_{i}' - {h_{21}x_{i} + h_{22}y_{i} + h_{23} \over h_{31}x_{i} + h_{32}y_{i} + h_{33}})^{2}

  • 위 수식에서 h_{ij}(1 \leq i, j \leq 3) 는 호모그래피 행렬 H의 원소를 나타내고 다음과 같은 관계를 만족시킨다.

s_{i} \left[ \begin{array}{rrr} x_{i}' \\ y_{i}' \\ 1 \end{array} \right] \sim H \left[ \begin{array}{rrr} x_{i} \\ y_{i} \\ 1 \end{array} \right] = \left[ \begin{array}{rrr} h_{11} & h_{12} & h_{13} \\ h_{21} & h_{22} & h_{23} \\ h_{31} & h_{32} & h_{33} \end{array} \right] \left[ \begin{array}{rrr} x_{i} \\ y_{i} \\ 1 \end{array} \right]

  • findHomography() 함수의 method 인자에 기본값인 0을 지정하면 입력 점과 출력 점을 모두 사용하는 최소자승법(least squares)으로 호모그래피 행렬을 계산한다.
    • 그러나 일반적으로는 특징점 매칭 결과로부터 호모그래피를 계산할 때 최소자승법을 사용하면 호모그래피가 제대로 계산되지 않는다.
    • 잘못 매칭된 점들처럼 오차가 큰 입력 정보를 이상치(outlier)라고 부르며, 이상치가 많이 존재하는 경우에는 호모그래피 계산 방법 method를 LMEDS, RANSAC, RHO 방법으로 설정하는 것이 좋다.
    • LMEDS 메서드는 보통 이상치가 50% 이하인 경우 올바르게 작동하며, RANSAC, RHO 방법은 이상치가 50% 이상이라도 호모그래피 행렬을 잘 찾아주는 편이다.
    • RANSAC과 RHO 방법을 사용할 경우에는 srcPoints와 dstPoints에 저장된 점이 이상치가 아니라고 판단하기 위한 임계값을 설정해야 하며, 이 값은 ransacReprojThreshold 인자로 지정한다.
    • 만약 h * srcPoints_{i} dstPoints_{i} 사이의 거리가 ransacReprojThreshold 보다 작으면 정상치(inlier)로 간주한다.
  • Note)
    • RANSAC(RANdom SAmple Consensus) 알고리즘은 이상치가 포함된 입력 데이터로부터 수학적 모델 파라미터를 효과적으로 결정하는 알고리즘이다.
    • RANSAC 알고리즘으로 호모그래피를 계산하는 경우, 다수의 특징점 매칭 정보로부터 네 개의 대응점을 임의로 추출한다. 이 대응점 정보를 이용하여 3 x 3 호모그래피 행렬을 계산하고, 나머지 특징점 매칭 쌍 중에서 현재 구한 호모그래피 행렬에 부합되는 매칭 쌍 개수를 센다.
    • 그리고 다시 임의로 네 개의 대응점을 추출하고, 호모그래피 행렬 계산과 해당 호모그래피에 부합되는 매칭 쌍 개수 세는 작업을 반복한다.
    • 이 작업을 여러 번 반복한 후, 가장 많은 매칭 쌍의 지지를 받은 호모그래피 행렬을 최종 호모그래피 행렬로 결정하는 방식이 RANSAC이다.
void find_homography()
{
Mat src1 = imread("box.png", IMREAD_GRAYSCALE);
Mat src2 = imread("box_in_scene.png", IMREAD_GRAYSCALE);

if (src1.empty() || src2.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Ptr<Feature2D> feature = ORB::create();

vector<KeyPoint> keypoints1, keypoints2;
Mat desc1, desc2;
feature->detectAndCompute(src1, Mat(), keypoints1, desc1);
feature->detectAndCompute(src2, Mat(), keypoints2, desc2);

Ptr<DescriptorMatcher> matcher = BFMatcher::create(NORM_HAMMING);

vector<DMatch> matches;
matcher->match(desc1, desc2, matches);

std::sort(matches.begin(), matches.end());
vector<DMatch> good_matches(matches.begin(), matches.begin() + 50);

Mat dst;
drawMatches(src1, keypoints1, src2, keypoints2, matches, dst, Scalar::all(-1), Scalar::all(-1), vector<char>(), DrawmatchesFlags::NOT_DRAW_SINGLE_POINTS);

vector<Point2f> pts1, pts2;
for (size_t i = 0; i < god_matches.size(); i++)
{
pts1.push_back(keypoints1[good_matches[i].queryIdx].pt);
pts2.push_back(keypoints2[good_matches[i].trainIdx].pt);
}

Mat H = findHomography(pts1, pts2, RANSAC);

vector<Point2f> corners1, corners2;
corners1.push_back(Point2f(0, 0));
corners1.push_back(Point2f(src1.cols-1.f, 0));
corners1.push_back(Point2f(src1.cols-1.f, src1.rows-1.f));
corners1.push_back(Point2f(0, src1.rows-1.f));
perspectiveTransform(corners1, corners2, H);

vector<Point> corners_dst;
for (Point2f pt : corners2)
{
corners_dst.push_back(Point(cvRound(pt.x + src1.cols), cvRound(pt.y)));
}

polylines(dst, corners_dst, true, Scalar(0, 255, 0), 2, LINE_AA);

imshow("dst", dst);

waitKey();
destroyAllWindows();
}

영상 이어붙이기

  • 영상 이어 붙이기(image stitching)는 여러 장의 영상을 서로 이어 붙여서 하나의 큰 영상을 만드는 기법이다.
    • 영상 이어 붙이기로 만들어진 영상을 파노라마 영상(panorama image)라고 부르며, 많은 디지털 카메라 또는 스마트폰 카메라 앱에서도 파노라마 영상을 만드는 기능을 제공하고 있다.
    • 영앗 이어 붙이기에서 입력으로 사용할 영상은 서로 일정 비율 이상으로 겹치는 영역이 존재해야 하며, 서로 같은 위치를 분간할 수 있도록 유효한 특징점이 많이 있어야 한다.
  • 영상 이어 붙이기를 수행하려면 입력 영상에서 특징점을 검출하고, 서로 매칭을 수행하여 호모그래피를 구해야 한다.
    • 그리고 구해진 호모그래피 행렬을 기반으로 입력 영상을 변형하여 서로 이어 붙이는 작업을 수행한다.
    • 이때 영상을 이어 붙인 결과가 자연스럽게 보이도록 이어 붙인 영사으이 밝기를 적절하게 보정하는 블렌딩(blending) 처리도 해야 한다.
  • OpenCV는 이러한 일련의 영상 이어 붙이기 작업ㅇ르 수행하는 Stitcher 클래스를 제공한다.
    • Stitcher 객체는 Stitcher ::create() 함수를 이용해서 생성할 수 있다. Stitcher ::create() 함수는 하나의 인자 mode를 가지지만 기본값으로 Stitcher::PANORAMA가 정의되어 있으므로 생략할 수 있다.
    • 만약 스캐너 등으로 여러 장의 영상을 이어 붙이려면 Stitcher::SCANS를 mode 값으로 지정한다.
    • Stitcher::PANORAMA는 입력 영상들이 서로 투시 변환(또는 호모그래피) 관계에 있다고 가정하고 Stitcher::SCANS 모드는 입력 영상들이 서로 어파인 관계라고 간주한다.
int main(int argc, char* argv[])
{
if (argc < 3)
{
cerr << "Usage: stitching.exe <image_file1> <image_file2> [<image_file3>...]" << endl;
return -1;
}

vector<Mat> imgs;
for (int i = 1; i < argc; i++)
{
Mat img = imread(argv[i]);

if (img.empty())
{
cerr << "Image load failed!" << endl;
return -1;
}
imgs.push_back(img);
}

Ptr<Stitcher> stitcher = Stitcher::create();

Mat dst;
Stitcher::Status status = stitcher->stitch(imgs, dst);

if (status != Stitcher::Status::OK)
{
cerr << "Error on stitching!" << endl;
return -1;
}

imwrite("result.jpg", dst);
imshow("dst", dst);

waitKey();
return 0;
}

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 객체 검출

템플릿 매칭

  • 입력 영상에서 작은 크기의 부분 영상 위치를 찾아내고 싶은 경우에 주로 템플릿 매치(template matching) 기법을 사용한다. 
    • 여기서 템플릿(template)은 찾고자 하는 대상이 되는 작은 크기의 영상을 의미한다.
  • 아래 그림은 템플릿 매칭의 동작 방식이다.
    • (a)와 같이 템플릿 영상을 입력 영상 전체 영역에 대해 이동하면서 템플릿 영상과 입력 영상 부분 영상과의 유사도(similarity) 또는 비유사도(dissimilarity)를 계산한다.
    • 유사도를 계산할 경우에는 템플릿 영상과 비슷한 부분 영상 위치에서 값이 크게 나타나고, 반대로 비유사도를 계산할 경우 템플릿 영상과 비슷한 부분에서 값이 작게 나타난다.

  • OpenCV에서는 matchTemplate() 함수를 이용하여 템플릿 매칭을 수행할 수 있다.
    • matchTemplate() 함수는 입력 영상 image에서 템플릿 영상 templ을 이용하여 템플릿 매칭을 수행하고 그 결과로 생성되는 유사도 맵 또는 비유사도 맵을 result 인자로 반환한다.
    • 만약 image 영상의 크기가 W x H이고 templ 영상의 크기가 w x h 인경우, result 행렬의 크기는 (W-w+1) x (H-h+1)로 결정된다.
    • matchTemplate() 함수에서 템플릿 영상과 입력 영상 간의 비교 방식은 method 인자로 설정할 수 있다. method인자는 TemplateMatchModes 열거형 상수 중 하나를 지정할 수 있다.
TemplateMatchModes 설명
TM_SQDIFF

제곱차 매칭 방법
R(x, y) = \sum_{x', y'}(T(x', y') - I(x+x', y+y'))^{2}

TM_SQDIFF_NORMED 정규화된 제곱차 매칭 방법
R(x, y) = { \sum_{x', y'}(T(x', y') - I(x+x', y+y'))^{2} \over \sqrt{\sum_{x', y'}T(x', y')^{2} \cdot \sum_{x', y'}I(x+x', y+y')^{2}} }
TM_CCORR 상관관계 매칭 방법
R(x, y) = \sum_{x', y'}T(x', y') \cdot I(x+x', y+y')
TM_CCORR_NORMED 정규화된 상관관계 매칭 방법
R(x, y) = { \sum_{x', y'}T(x', y') \cdot I(x+x', y+y') \over \sqrt{ \sum_{x', y'}T(x', y')^{2} \cdot I(x+x', y+y')^{2}}}
TM_CCOEFF 상관계수 매칭 방법
R(x, y) = \sum_{x', y'}T'(x', y') \cdot I'(x+x', y+y')
T'(x', y') = T(x', y') - {1 \over w \cdot h} \cdot \sum_{x'', y''}T'(x'', y'')
I'(x+x', y+y') = I(x+x', y+y') - {1 \over w \cdot h} \cdot \sum_{x'', y''} I(x+x'', y+y'')
TM_CCOEFF_NORMED 정규화된 상관계수 매칭 방법
R(x, y) = { \sum_{x', y'}T'(x', y') \cdot I'(x+x', y+y') \over \sqrt{\sum_{x', y'}T'(x', y')^{2} \cdot \sum_{x',y'}I'(x+x', y+y')^{2}} }
  • TM_SQDIFF는 제곱차(squared difference) 매칭 방법을 의미하며, 이 경우 두 영상이 완벽하게 일치하면 0이 되고, 서로 유사하지 않으면 0보다 큰 양수를 갖는다.
  • TM_CCORR는 상관관계(correlation) 매칭 방법을 의미하며, 이 경우 두 영상이 유사하면 큰 양수가 나오고 유사핮 ㅣ않으면 작은 값이 나온다.
  • TM_CCOEFF는 상관계수(correlation coefficient) 매칭 방법을 의미하며, 이는 비교할 두 영상을 미리 평균 밝기로 보정한 후 상관관계 매칭을 수행하는 방식이다. TM_CCOEFF 방법은 두 영상이 유사하면 큰 양수가 나오고 유사하지 않으면 0에 가까운 양수 또는 음수가 나온다.
  • TM_SQDIFF, TM_CCORR, TM_CCOEFF 방법에 대해 영상의 밝기 차이 영향을 줄여 주는 정규화 수식이 된 TM_SQDIFF_NORMED, TM_CCORR_NORMED, TM_CCOEFF_NORMED 방법도 제공된다.
    • TM_CCORR_NORMED 방법은 결과값이 0-1 사이의 실수로 나타나고, TM_CCOEFF_NORMED 방법은 -1에서 1사이의 실수로 나타난다. 두 방법 모두 결과가 1에 가까울 수록 매칭이 잘 되었음을 의미한다.
  • 여러 매칭 방법 중에서 상관계수 매칭 방법이 좋은 결과를 제공하는 것으로 알려져 있다.
    • 그러나 계산 수식이 복잡하고 실제 동작시 연산량이 많다는 점을 고려해야 한다.
    • 제곱차 매칭 방법을 사용할 경우, result 결과 행렬에서 최솟값 위치를 가장 매칭이 잘 된 위치로 선택해야 한다.
    • 반면 상관관계 또는 상관계수 매칭 방법을 사용할 경우에는 result 결과 행렬에서 최댓값 위치가 가장 매칭이 잘 된 위치이다.
    • 참고로 result 행렬에서 최솟값 또는 최댓값 위치는 OpenCV의 minMaxLoc() 함수를 이용하여 알아낼 수 있다.
void template_matching()
{
Mat img = imread("circuit.bmp", IMREAD_COLOR);
Mat templ = imread("crystal.bmp", IMREAD_COLOR);

if (img.empty() || templ.empty())
{
cerr << "Image load failed!" << endl;
return;
}

img = img + Scalar(50, 50, 50);

Mat noise(img.size(), CV_32SC3);
randn(noise, 0, 10);
add(img, noise, img, Mat(), CV_8UC3);

Mat res, res_norm;
matchTemplate(img, templ, res, TM_CCOEFF_NORMED);
normalize(res, res_norm, 0, 255, NORM_MINMAX, CV_8U);

double maxv;
Point maxloc;
minMaxLoc(res, 0, &maxv, 0, &maxloc);
cout << "maxv: " << maxv << endl;

rectangle(img, Rect(maxloc.x, maxloc.y, templ.cols, templ.rows), Scalar(0, 0, 255), 2);

imshow("templ", templ);
imshow("res_norm", res_norm);
imshow("img", img);

waitKey(0);
destroyAllWindows();
}
  • Note) 템플릿 매칭은 알고리즘 특성상 입력 영상이 최전되거나 크기가 변경되면 제대로 동작하지 않는다. 또한 찾고자 하는 템플릿 영사잉 다른 객체에 의해 가려져도 좋은 결과를 기대할 수 없다. 이런 경우에는 템플릿 매칭 방법보다는 특징점 매칭 기법을 사용하는 것이 낫다.

캐스게이드 분류기와 얼굴 검출

  • OpenCV에서 제공하는 얼굴 검출 기능은 2001년 비올라(P. Viola)와 존스(M. Jones)가 발표한 부스팅(boosting) 기반의 캐스케이드 분류기(cascade classifier) 알고리즘 기반으로 만들어졌다.
    • 비올라와 존스가 개발한 객체 검출 알고리즘은 기본적으로 다양한 객체를 검출할 수 있지만, 특히 얼굴 검출에 적용되어 속도와 정확도를 인정받은 기술이다.
  • 비올라-존스 얼굴 검출 알고리즘은 기본적으로 영상은 24 x 24 크기로 정규화한 후, 유사-하르필터(Haar-like filter) 집합으로부터 특징 정보를 추출하여 얼굴 여부를 판별한다.
    • 유사-하르 필터란 흑백 사각형이 서로 붙어 있는 형태로 구성된 필터이며, 24 x 24 영상에서 만들 수 있는 유사-하르 필터의 예는 아래 그림과 같다.
    • 유사-하르 필터 형태에서 흰색 영역 픽셀 값은 모두 더하고, 검은색 영역 픽셀 값은 모두 빼서 하나의 특징 값을 얻을 수 있다.
    • 사람의 정면 얼굴 형태가 전형적으로 밝은 영역(이마, 미간, 볼 등)과 어두운 영역(눈썹, 입술 등)이 정해져 있기 때문에 유사-하르 필터로 구한 특징 값은 얼굴을 판별하는 용도로 사용할 수 있다.

  • 그러나 24 x 24 크기에서 유사-하르 필터를 약 18만개 생성할 수 있고, 픽셀 값의 합과 차를 계산하는 것이 시간이 오래 걸린다는 점이 문제가 되었기 때문에 비올라와 존스는 에이다부스트(adaboost) 알고리즘과 적분 영상(integral image)를 이용하여 이 문제를 해결하였다.
    • 에이다부스트 알고리즘은 수많은 유사-하르 필터 중에서 얼굴 검출에 효과적인 필터를 선별하는 역할을 수행한다.
    • 실네 논문에서는 약 6000개의 유사-하르 필터를 선별하였으며, 이 중 얼굴 검출에 가장 유용하다고 판별된 유사-하르 피러의 일부가 아래 그림과 같다.

  • 에이다부스트 알고리즘에 의해 24 x 24 부분 영상에서 검사할 특징 개수가 약 6000개로 감소하였지만, 입력 영상 전체에서 부분 영상을 추출해서 검사해야 하기 때문에 여전히 연산량이 부담될 수 있다.
    • 더구나 나타날 수 있는 얼굴 크기가 다양하기 때문에 보통 입력 영상의 크기를 줄여 가면서 전체 영역에 대한 검사를 다시 수행해야 한다.
    • 비올라와 존스는 대부분의 영상에 얼굴이 한두 개 있을 뿐이고 나머지 대부분의 영역은 얼굴이 아니라는 정메서 캐스케이드(cascade) 구조라는 새로운 방식을 도입하여 얼굴이 아닌 영역을 빠르게 걸러 내는 방식을 사용한다.
  • 아래 그림은 얼굴이 아닌 영역을 걸러 내는 캐스케이드 구조이다.
    • 캐스케이드 구조 1단계에서는 얼굴 검출에 가장 유용한 유사-하르 필터 하나를 사용하여, 얼굴이 아니라고 판단되면 이후의 유사-하르 필터 계산은 수행하지 않는다.
    • 1단계를 통과하면 2단계에서 유사-하르 필터 다섯 개를 사용하여 얼굴이 아닌지를 검사하고, 얼굴이 아니라고 판단되면 이후 단계의 검사는 수행하지 않는다.
    • 이러한 방식으로 얼굴이 아닌 영역을 빠르게 제거함으로써 비올라-존스 얼굴 검출 알고리즘은 다른 얼굴 검출 방식보다 약 15배 빠르게 동작하는 성능을 보여줬다.

  • OpenCV는 비올라-존스 알고리즘을 구형하여 객체를 분류할 수 있는 CascadeClassifier 클래스를 제공한다.
    • CascadeClassifier 클래스는 미리 훈련된 객체 검출 분류기 XML 파일을 불러오는 기능과 주어진 영상에서 객체를 검출하는 기능으로 이루어져있다.
    • CascadeClassifier 객체를 생성한 후에 미리 훈련된 분류기 정보를 불러올 수 있는데, 분류기 정보는 XML 파일 형식으로 저장되어 있으며, OpenCV는 미리 훈련된 얼굴 검출, 눈 검출 등을 위한 분류기 XML 파일을 제공한다.
    • 이러한 미리 훈련된 분류기 XML 파일은 %OPENCV_DIR%\etc\haarcascades 폴더에 존재한다.
    • 이 폴더에서 찾을 수 있는 XML 파일 이름과 검출 대상에 대한 설명은 아래 표와 같다.
    • OpenCV는 하나의 검출 대상에 대해 서로 다른 방법으로 훈련된 여러 개의 XML 파일을 제공한다.
XML 파일 이름 검출 대상
haarcascade_frontalface_default.xml
haarcascade_frontalface_alt.xml
haarcascade_frontalface_alt2.xml
haarcascade_frontalface_alt_tree.xml
정면 얼굴 검출
haarcascade_profileface.xml 측면 얼굴 검출
haarcascade_smile.xml 웃음 검출

haarcascade_eye.xml
haarcascade_eye.tree_eyeglasses.xml
haarcascade_lefteye_2splits.xml
haarcascade_righteye_2splits.xml

눈 검출
haarcascade_frontalcatface.xml
haarcascade_frontalcatface_extended.xml
고양이 얼굴 검출
haarcascade_fullbody.xml 사람의 전신 검출
haarcascade_upperbody.xml 사람의 상반신 검출
haarcascade_lowerbody.xml 사람의 하반신 검출
haarcascade_russial_plate_number.xml
haarcascade_licence_plate_rus_16statges.xml
러시아 자동차 번호판 검출
  • XML 파일을 정상적으로 불러왔다면 CascadeClassfier::detectMultiScale() 멤버 함수를 이용하여 객체 검출을 수행할 수 있다.
    • CascadeClassfier::detectMultiScale() 함수는 입력 영상 image에서 다양한 크기의 객체 사각형 영역을 검출한다. 만약 입력 영상 image가 3채널 컬러 영상이면 함수 내부에서 그레이스케일 형식으로 변환하여 객체를 검출한다.
    • 각각의 사각형 영역 정보는 Rect 클래스를 이용하여 표현하고 vector<Rect> 타입의 인자 objects에 검출된 모든 사각형 정보가 저장된다.
    • scaleFactor 인자는 검색 윈도우의 확대 비율을 지정한다.
    • CascadeClassfier::detectMultiScale() 함수는 다양한 크기의 얼굴을 검출하기 위하여 처음에는 작은 크기의 검색 윈도우를 이용하여 객체를 검출하고 이후 scaleFactor 값의 비율로 검색 윈도우 크기를 확대시키면서 여러 번 객체를 검출한다.
    • minNeighbors 인자에는 검출할 객체 영역에서 얼마나 많은 사각형이 중복되어 검출되어야 최종적으로 객체 영역으로 설정할지를 지정한다. minNeighbors 값을 기본값인 3으로 설정하면 검출된 사각형이 최소 3개 이상 중첩되어야 최종적으로 객체 영역으로 판단한다.
void detect_face()
{
Mat src = imread("kids.png");

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

CascadeClassfier classfier("haarcascade_frontalface_default.xml");

if (classifier.empty())
{
cerr << "XML load failed!" << endl;
return;
}

vector<Rect> faces;
classifier.detectMultiScale(src, faces);

for (Rect rc : faces)
{
rectangle(src, rc, Scalar(255, 0, 255), 2);
}

imshow("src", src);

waitKey(0);
destroyAllWindows();
}
  • 만일 얼굴 안에서 눈을 검출하고자 한다면, 먼저 얼굴을 검출하고 얼굴 영역 안에서만 눈을 검출하는 것이 효율적이다.
void detect_eyes()
{
Mat src = imread("kids.png");

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

CascadeClassfier face_classfier("haarcascade_frontalface_default.xml");
CascadeClassfier eye_classfier("haarcascade_eye.xml");

if (face_classifier.empty() || eye_classifier.empty())
{
cerr << "XML load failed!" << endl;
return;
}

vector<Rect> faces;
classifier.detectMultiScale(src, faces);

for (Rect face : faces)
{
rectangle(src, face, Scalar(255, 0, 255), 2);

Mat faceROI = src(face);
vector<Rect> eyes;
eye_classifier.detectMultiScale(faceROI, eyes);

for (Rect eye : eyes)
{
Point center(eye.x + eye.width / 2, eye.y + eye.height / 2);
circle(faceROI, center, eye.width / 2, Scalar(255, 0, 0), 2, LINE_AA);
}
}

imshow("src", src);

waitKey(0);
destroyAllWindows();
}

HOG 알고리즘과 보행자 검출

  • HOG(Histograms of Oriented Gradients)는 그래디언트 방향 히스토그램을 의미한다.
    • 다랄(N. Dalal)과 트릭스(B. Triggs)는 사람이 서 있는 영상에서 그래디언트를 구하고, 그래디언트의 크기와 방향 성분을 이용하여 사람이 서 있는 형태에 대한 특징 벡터를 정의하였다. 그리고 머신 러닝의 일종인 서포트 벡터 머신(SVM, Support Vector Machine) 알고리즘을 이용하여 입력 영상에서 보행자 위치를 검출하는 방법을 제안하였다.
  • 아래 그림을 예로 HOG를 계산하는 방법에 대해 설명해 보겠다.
    • 보행자 검출을 위한 HOG는 기본적으로 64 x 128 크기의 영상에서 계산한다. HOG 알고리즘은 먼저 입력 영상으로부터 그래디언트를 계산한다. 그래디언트는 크기와 방향 성분으로 계산하며, 방향 성분은 0도부터 180도까지로 설정한다.
    • 그 다음 입력 영상을 8 x 8 크기 단위로 분할하는데, 각각의 8 x 8 부분 영상을 셀(cell)이라 한다. 64 x 128 영상에서 셀은 ㅏ로 방향으로 8개, 세로 방향으로 16개 생성된다.
    • 각각의 셀로부터 그래디언트 방향 성분에 대한 히스토그램을 구하며, 이때 방향 성분을 20도 단위로 구분하면 총 9개의 빈으로 구성된 방향 히스토그램이 만들어진다. 그리고 인접한 4개의 셀을 합쳐서 블록(block)이라고 정의한다.
  • 아래 그림의 (b)에서 노란색 실선은 셀을 구분하는 선이고, 빨간색 사각형은 블록 하나를 나타낸다.
    • 하나의 블록에는 네 개의 셀이 있고, 각 셀에는 9개의 빈으로 구성된 히스토그램 정보가 있으므로 블록 하나에는 총 36개의 실수 값으로 이루어진 방향 히스토그램 정보가 추출된다.
    • 블록은 가로와 세로 방향으로 각각 한 개의 셀만큼 이동하면서 정의한다. 그러므로 64 x 128 영상에서 블록은 가로 방향으로 7개, 세로 방향으로 15개 정의할 수 있다.
    • 결국 영상에서 105개의 블록이 추출될 수 있고, 전체 블록에서 추출되는 방향 히스토그램 실수 값 개수는 3780개가 된다. 이 3780개의 실수 값이 64 x 128 영상을 표현하는 HOG 특징 벡터 역할을 한다.
    • 아래 그림의 (c)는 각 셀에서 계산된 그래디언트 방향 히스토그램을 비주얼하게 표현한 결과이다.

  • 다랄과 트릭스는 수천 장의 보행자 영상과 보행자가 아닌 영상에서 HOG 특징 벡터를 추출하였고, 이 두 특징 벡터를 구분하기 위해 SVM 알고리즘을 사용했다.
    • SVM은 두 개의 클래스를 효과적으로 분리하는 능력을 가진 머신 러닝 알고리즘으로 다랄과 트릭스는 수천 개의 보행자 특징 벡터와 보행자가 아닌 특징 벡터를 이용하여 SVM을 훈련시켰고, 효과적인 보행자 검출 방법을 완성시켰다.
    • HOG와 SVM을 이용한 객체 검출 기술은 이후 보행자 검출 뿐만 아니라 다양한 형태의 객체 검출에서도 응용되었다.
  • OpenCV에서는 HOG 알고리즘을 구현한 HOGDescriptor 클래스를 제공한다.
    • HOGDescriptor 클래스를 이용하면 특정 객체의 HOG 기술자를 쉽게 구할 수 있다. 또한 HOGDescriptor  클래스는 보행자 검출을 위한 용도로 미리 계산된 HOG 기술자 정보를 제공한다.
    • HOGDescriptor 클래스를 이용하여 원하는 객체를 검출하려면 먼저 검출할 객체에 대해 훈련된 SVM 분류기 계수를 HOGDescriptor::setSVMDetector() 함수에 등록해야 한다.
    • 보행자 검출이 목적이라면 HOGDescriptor::getDefaultPeopleDetector() 함수가 반환한 분류기 계수를 HOGDescriptor::setSVMDetector() 함수 인자로 전달하면 된다.
    • HOG 기술자를 이용하여 실제 입력 영상에서 객체 영역을 검출하려면 HOGDescriptor::detectMultiScale() 멤버함수를 이용하면 된다.
VideoCapture cap("vtest.avi");

if (!cap.isOpened())
{
cerr << "Video open failed!" << endl;
return -1;
}

HOGDescriptor hog;
hog.setSVMDetector(HOGDescriptor::getDefaultPeopleDetector());

Mat frame;
while (true)
{
cap >> frame;
if (frame.empty())
break;

vector<Rect> detected;
hog.detectMultiScale(frame, detected);

for (Rect r : detected)
{
Scalar c = Scalar(rand() % 256, rand() % 256, rand() % 256);
rectangle(frame, r, c, 3);
}

imshow("frame", frame);

if (waitKey(10) == 27)
break;

return 0;
}

QR 코드 검출

  • 입력 영상에서 QR 코드를 인식하려면 먼저 QR 코드 세 모서리에 포함된 흑백 정사각형 패턴을 찾아 QR 코드 전체 영역 위치를 알아내야 한다.
    • 그리고 검출된 QR 코드를 정사각형 형태로 투시 변환한 후, QR 코드 내부에 포함된 흑백 격자 무늬를 해석하여 문자열을 추출해야 한다.
    • 이러한 일련의 연산은 매우 복잡하고 정교한 영상 처리를 필요로 하는데, 다행히 OpenCV는 4.0 버전부터 QR 코드를 검출하고 문자열을 해석하는 기능을 제공한다.
  • OpenCV에서 QR 코드를 검출하고 해것하는 기능은 QRCodeDetector 클래스에 구현되어 있다.
    • QRCodeDetector::detect() 함수를 이용하면 QR 코드 영역을 검출할 수 있고, QRCodeDetector::decode() 함수를 이용하면 QR 코드에 암호화 되어 있는 문자열을 검출할 수 있다.
    • QRCodeDetector::detectAndDecode()는 위 과정을 한 번에 수행하여 최종적으로 해석된 문자열을 반환한다.
void decode_qrcode()
{
VideoCapture cap(0);

if (!cap.isOpened())
{
cerr << "Camera open failed!" << endl;
return;
}

QRCodeDetector detector;

Mat frame;
while(true)
{
cap >> frame;

if (frame.empty())
{
cerr << "Frame load failed!" << endl;
break;
}

vector<Point> points;
String info detector.detectAndDecode(frame, points);

if (!info.empty())
{
polylines(frame, points, true, Scalar(0, 0, 255), 2);
putText(frame, info, Point(10, 30), FONT_HERSHEY_DUPLEX, 1, Scalar(0, 0, 255));

imshow("frame", frame);

if (waitKey(1) == 27)
break;
}
}
}

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 레이블링과 외곽선 검출

레이블링

레이블링의 이해

  • 이진화를 수행하면 주요 객체와 배경 영역을 구분할 수 있다. 배경과 객체를 구분하였다면 다시 각각의 객체를 구분하고 분석하는 작업이 필요한데 이때 사용할 수 있는 기법이 레이블링(labeling)이다.
  • 레이블링은 영상 내에 존재하는 객체 픽셀 집합에 고유 번호를 매기는 작업으로 연결된 구성 요소 레이블링(connected components labeling)이라고도 한다.
    • 레이블링 기법을 이용하여 각 객체의 위치와 크기 등 정보를 추출하는 작업은 객체 인식을 위한 전처리 과정으로 자주 사용된다.
  • 영상의 레이블링은 일반적으로 이진화된 영상에서 수행된다. 이때 검은색 픽셀은 배경으로 간주하고 흰색 픽셀은 객체로 간주한다. (정확하게는 입력 영상의 픽셀값이 0이면 배경, 0이 아니면 객체로 인식한다.)
    • 하나의 객체는 한 개 이상의 인접한 픽셀로 이루어지며, 하나의 객체를 구성하는 모든 픽셀에는 같은 레이블 번호가 지정된다.
  • 특정 픽셀과 이웃한 픽셀의 연결 관계는 크게 두 가지 방식으로 정의할 수 있다.
    • 첫 번째는 특정 픽셀의 상하좌우로 붙어 있는 픽셀끼리 연결되어 있다고 정의하는 4-방향 연결성(4-way connectivity)이고
    • 다른 하나는 상하좌우 뿐만 아니라 대각선 방향으로 인접한 픽셀도 연결되어 있다고 간주하는 8-방향 연결성(8-way connectivity)이다.

  • 이진 영상에 레이블링을 수행하면 각각의 객체 영역에 고유의 번호가 매겨진 2차원 정수 행렬이 만들어진다.
    • 레이블링에 의해 만들어지는 이러한 2차원 정수 행렬을 레이블 맵(label map)이라한다. 레이블링을 수행하는 알고리즘은 매우 다양하게 존재하지만 모두 같은 형태의 레이블 맵을 생성한다.
  • 작은 크기의 입력 영상에 대해 레이블링을 수행했을 때 만들어지는 레이블 맵이 아래 그림과 같다.
    • (a)는 이진화된 영상이며, (b)는 레이블링을 수행한 후 정수로 구성된 레이블 맵 행렬이 생성된 것이다.

  • OpenCV는 3.0.0 부터 레이블링 함수를 제공하는데, connectedCompoents()가 그것이다.
    • connectedCompoents() 함수는 입력 영상 image에 대해 레이블링을 수행하여 구한 레이블 맵 labels를 반환한다.
    • connectedCompoents() 함수의 입력 image에는 보통 threshold() 또는 adaptiveThreshold() 등 함수를 통해 얻은 이진 영상을 지정한다. 회색이 포함된 그레이스케일 영상을 입력으로 사용할 경우 픽셀 값이 0이 아니면 객체 픽셀로 갖누한다.
    • labels 인자에는 Mat 자료형의 변수 이름을 전달한다.

레이블링 응용

  • 레이블링 수행 후에 각각의 객체 영역이 어느 위치에 어느 크기로 존재하는지 확인할 필요가 있는데, 이를 반복문을 이용하여 직접 구현하는 것은 꽤 번거로운 일이다. 다행히 OpenCV에서는 레이블 맵과 각 객체 영역의 통계 정보를 한번에 반환하는 connectedComponentsWithStats() 함수를 제공해준다.
    • connectedComponentsWithStats() 함수는 connectedComponents() 함수 인자에 stats와 centroids가 추가된 형태이다. 보통 stats와 centroids 인자에는 Mat 자료형 변수를 지정한다.
  • 앞선 8×8 영상에 대해 생성되는 labels, stats, cetroids 행렬은 아래 그림과 같다.
    • (a)는 레이블 맵을 담고 있는 labels 행렬이고, (b)는 CV_32SC1 타입의 stats 행렬이고 (c)는 CV_64FC1 타입의 centroids 행렬이다.
    • stats 행렬의 행 개수는 레이블 개수와 같고, 열 개수는 항상 5이다.
      • stats 행렬의 각 행은 labels 행렬에 나타난 번호에 해당하는 영역을 나타낸다.
      • 첫 번째 행은 배경 영역 정보를 담고 있고, 두 번째 행부터는 1번부터 시작하는 객체 영역에 대한 정보를 담고 있다.
      • stats 행렬의 각 열은 차례대로 특정 영역을 감싸는 바운딩 박스의 x좌표, y좌표, 가로크기, 세로크기, 해당 영역의 픽셀 개수를 담고 있다.
      • centroids 행렬의 행 개수는 레이블과 같고 열 개수는 항상 2이다.
        • centroids 행렬의 각 열은 차례대로 각 영역의 무게중심 x좌표와 y좌표이다.
        • 무게중심 좌표는 해당 객체에 속하는 픽셀의 모든 x, y좌표를 더한 후 픽셀 개수로 나눈 값이 된다.

외곽선 검출

외곽선 검출

  • 객체의 외곽선(contour)은 객체 영역 픽셀 중에서 배경 여역과 인접한 일련의 픽셀을 의미한다.
    • 보통 검은색 배경 안에 있는 흰색 객체 영역에서 가장 최외곽에 있는 픽셀을 찾아 외곽선으로 정의한다.
    • 만약 흰색 객체 영역 안에 검은색 배경 여역인 홀(hole)이 존재한다면 홀을 둘러싸고 있는 객체 픽셀들도 외곽선으로 검출할 수 있다.
    • 즉 객체의 외곽선은 객체 바깥쪽 외곽선과 안쪽 홀 외곽선으로 구분할 수 있다.
  • 객체 하나의 외곽선은 여러 개의 점으로 구성된다. 그러므로 객체 하나의 외곽선 정보는 vector<Point> 타입으로 저장할 수 있다.
    • 또한 하나의 영상에는 여러 개의 객체가 존재할 수 있으므로 영상 하나에서 추출된 전체 객체의 외곽선 정보는 vector<vector<Point>> 타입으로 표현할 수 있다.
  • 외곽선 검출이 어떻게 동작하는지에 대해 아래 그림으로 표현하였다.
    • 아래 그림의 (a)는 원본영상이며, (b)는 외곽선 검출을 수행하여 외곽선 점들을 찾아낸 것이다.
    • 검출된 외곽선 점들의 좌표는 contours 변수에 모두 저장된다. contours 변수로부터 전체 객체 개수를 알고 싶다면 contours.size() 를 확인하면 된다.

  • OpenCV에서 영상 내부 객체들의 외곽선을 검출하는 함수 이름은 findContours()이다. 이 함수는 hierarchy 인자가 있는 형태와 없는 형태 두 가지로 정의되어 있다.
    • findContours() 함수의 입력 영상으로는 보통 threshold() 등 함수에 의해 만들어진 이진 영상을 사용한다.
    • 실제 동작할 때는 입력 영상에서 픽셀 값이 0이 아니면 객체로 간주하여 외곽선을 검출한다.
    • contours 인자에는 검출된 외곽선 좌표 정보가 저장되고 보통 vector<vector<Point>> 타입의 변수를 지정한다.
    • hierarchy 인자에는 검출된 외곽선의 계층 정보가 저장되고, 보통 vector<Vec4i> 타입의 변수를 지정한다. Vec4i는 int 자료형 네 개를 저장할 수 있는 OpenCV 벡터 클래스로 i 번째 외곽선에 대해 hierarchy[i][0]에는 다음 외곽선 번호, hierarchy[i][1]에는 이전 외곽선 번호, hierarchy[i][2]에는 자식 외곽선 번호, hierarchy[i][3]에는 부모 외곽선 번호가 저장된다. 만약 계층 구조에서 해당 외곽선이 존재하지 않으면 -1이 저장된다.
    • findContours() 함수의 mode 인자에는 외곽선을 어떤 방식으로 검출할 것인지를 나타내는 검출 모드를 지정합니다. mode 인자에는 RetrievalModes 열거형 상수 중 하나를 지정할 수 있다.
RetrievalModes 설명
RETR_EXTERNAL 객체 바깥쪽 외곽선만 검색. 계층 구조는 만들지 않는다.
RETR_LIST 객체 바깥쪽과 안쪽 외곽선을 모두 검색. 계층 구조는 만들지 않는다.
RETR_CCOMP 모든 외곽선을 검색하고 2단계 계층 구조를 구성
RETR_TREE 모든 외곽선을 검색하고 전체 계층 구조를 구성
  • findContours() 함수의 method 인자에는 검출된 외곽선 점들의 좌표를 근사화하는 방법을 지정할 수 있다. method 인자에 지정할 수 있는 ContourApproximationModes 열거형 상수는 아래 표와 같다.
    • 저장되는 외곽선 점의 개수를 줄이고 싶다면 CHAIN_APPROX_SIMPLE 상수를 사용하면 유리하다.
    • CHAIN_APPROX_TC89_L1 또는 CHAIN_APPROX_TC89_KCOS 방식은 점의 개수는 많이 줄어들지만 외곽선 모양에 변화가 생기므로 주의해야 한다.
ContoursApporximationModes 설명
CHAIN_APPROX_NONE 모든 외곽선 점들의 좌표를 저장
CHAIN_APPROX_SIMPLE 외곽선 중에서 수평선, 수직선, 대각선 성분은 끝점만 저장
CHAIN_APPROX_TC89_L1 Teh & Chin L1 근사화를 적용
CHAIN_APPROX_TC89_KOCS Teh & Chin k cos 근사화를 적용
  • 외곽선 계층 구조에 대한 예시는 아래 그림과 같다.
    • 아래 그림의 외곽선 계층 구조는 외곿너의 포함 관계에 의해 결정된다.
    • 즉, 0번 외곽선 안에는 1, 2, 3번 홀 외곽선이 있으므로 0번은 1, 2, 3번의 부모고 1, 2, 3은 0의 자식이다. 한편 4번은 0번과 포함 관계가 없이 대등하므로 이전 외곽선 또는 다음 외곽선의 관계를 갖는다.

  • findContours() 함수에서 외곽선 검출 모드를 어떻게 지정하는지에 따라 검출되는 외곽선과 계층 구조가 서로 달라진다. 4가지 외각선 검출 모드에 따른 외곽선 검출 결과와 계층 구조는 아래 그림과 같다.
    • 아래 그림의 숫자는 위 그림의 외곽선 번호를 나타내며, 각 외곽선 번호 사이에 연결된 화살표는 계층 구조를 나타낸다.
    • 화살표가 오른쪽 외곽선 번호를 가리키면 다음 외곽선을 나타내고, 화살표가 왼쪽 외곽선 번호를 가리키면 이전 외곽선을 가리킨다. 화살표가 아래쪽을 가리키면 자식, 위쪽을 가리키면 부모 외곽선을 나타낸다.

  • findContours() 함수에서 RETR_EXTERNAL 외곽선 검출 모드를 사용하면 흰색 객체의 바깥쪽 외곽선만 검출하고 객체 내부의 홀 외곽선은 검출되지 않는다. 때문에 0번과 4번 외곽선만 검출된다.
    • RETR_LIST 검출 모드를 사용하면 바깥쪽과 안쪽 홀 외곽선을 모두 검출하지만 부모/자식 계층 정보는 생성되지 않는다.
    • RETR_CCOMP 검출 모드를 사용하면 모든 흰색 객체의 바깥쪽 외곽선을 먼저 검출하고, 각 객체 안의 홀 외곽선을 자식 외곽선으로 설정한다. 그러므로 RETR_CCOMP 모드에서는 상하 계층이 최대 두 개 층으로만 구성된다.
      • 만일 흰색 객체에 여러 홀이 존재하는 경우 그 중 하나만 자식 외곽선으로 설정된다. 그리고 각 홀 외곽선은 객체 바깥쪽 외곽선을 모두 부모 외곽선으로 설정한다.
    • RETR_TREE 검출 모드를 사용하면 외곽선 전체의 계층 구조를 생성한다.
  • findContours() 함수로 검출한 외곽선 정보를 이용하여 영상 위에 외곽선을 그리고 싶다면 drawContours() 함수를 사용할 수 있다.
    • drawContours() 함수는 findContours() 함수로 얻은 외곽선 정보를 이용하여 영상에 외곽선을 그린다. 전체 외곽선을 한번에 그릴 수도 있고, 특정 번호의 외곽선을 선택하여 그릴 수도 있고, 외곽선 계층 정보를 함께 지정할 경우 자식 외곽선도 함께 그릴 수 있다.

외곽선 처리 함수

  • 주어진 외곽선 점들을 감싸는 가장 작은 크기의 사각형, 즉 바운딩 박스를 구하고 싶다면 boundingRect() 함수를 사용한다.
    • 특정 객체의 바운딩 박스는 connectComponentsWithStats() 함수를 이용해서도 구할 수 있다. 다만 이미 외곽선 정보를 갖고 있는 경우에는 boundingRect() 함수를 이용하는 것이 효율적이다.
  • 외곽선 또는 점들을 감싸는 최소 크기의 회전된 사각형을 구하고 싶을 때는 minAreaRect() 함수를 사용한다.
  • 외곽선 또는 점들을 감싸는 최소 크기의 원을 구하고 싶을 때는 minEnclosingCircle() 함수를 사용한다.
  • 위 함수들을 사용한 예시는 아래 그림과 같다.

  • 임의의 곡선을 형성하는 점들의 집합을 갖고 있을 때, 해당 곡선의 길이를 구하고 싶다면 arcLength() 함수를 사용할 수 있다.
    • arcLength() 함수는 입력 곡선의 길이를 계산하여 반환한다. 입력 곡선 curve에는 보통 vector<Point> 또는 vector<Point2f> 자료형의 변수를 전달한다.
    • 두 번째 인자인 closed 값이 true이면 입력 곡선의 시작점과 끝점이 연결되어 있는 폐곡선이라고 간주하여 길이를 계산한다.
  • 임의의 외곽선 정보를 가지고 있을 때 외곽선이 감싸는 영역의 면적을 알고 싶다면 contourArea() 함수를 사용한다.
    • 예컨대 (0, 0), (10, 0), (0, 10) 세 점에 의해 결정되는 삼각형이 있을 때, 이 삼각형의 외곽선 길이와 면적을 구하려면 다음과 같이 코드를 작성할 수 있다.
vector<Point> pts = { Point(0, 0), Point(10, 0), Point(0, 10) };

cout << "len = " << arcLength(pts, true) << endl;
cout << "area = " << contourArea(pts) << endl;
  • OpenCV는 외곽선 또는 곡선을 근사화하는 approxPolyDP() 함수를 제공한다. approxPolyDP() 함수는 주어진 곡선의 형태를 단순화하여 작은 개수의 점으로 구성된 곡선을 생성한다.
    • approxPolyDP() 함수는 더글라스-포이커(Douglas-Peucker) 알고리즘을 사용하여 곡선 또는 다각형을 단순화시킨다.
    • 더글라스-포이커 알고리즘은 입력 외곽선에서 가장 멀리 떨어져 있는 두 점을 찾아 직선으로 연결하고, 해당 직선에서 가장 멀리 떨어져 있는 외곽선 상의 점을 찾아 근사화 점으로 추가한다.
    • 이러한 작업을 반복하다가 새로 추가할 외곽선 상의 점과 근사화에의한 직선과의 수직 거리가 epsilon 인자보다 작으면 근사화를 멈춘다. (epsilon 인자는 보통 입력 외곽선 또는 곡선 길이의 일정 비율로 지정한다. ex) arcLength(curve, true) * 0.02)
    • 아래 그림은 보트 모양의 객체 외곽선에 대해 더글라스-포이커 알고리즘으로 외곽선 근사화를 수행하는 과정을 나타낸 것이다.

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 이진화와 모폴로지

영상의 이진화

이진화

  • 영상의 이진화(binarization)는 영상의 각 픽셀을 두 개의 부류로 나누는 작업이다.
    • 예컨대 입력 영상을 주요 객체 영역과 배경으로 나누거나 또는 영상에서 중요도가 높은 관심 영역(ROI, Region Of Interest)과 그렇지 않은 비관심 영역으로 구분하는 용도로 이진화가 사용될 수 있다.
    • 원래 디지털 컴퓨팅 분야에서 이진화는 입력 값을 0 또는 1로 설정하지만 영상의 이진화는 픽셀 값을 0 또는 255로 설정한다.
    • 그러므로 이진화가 적용된 이진 영상은 흰색과 검은색 픽셀로만 구성된다.
  • 영상의 이진화는 기본적으로 영상의 각 픽셀값을 이용한다. 그레이스케일 영상에 대해 이진화를 수행하려면 영상의 픽셀 값이 특정 값보다 크면 255로 설정하고, 작으면 0으로 설정한다.
    • 이때 각 픽셀과의 크기 비교 대상이 되는 값을 임계값(threshold) 또는 문턱치라고 한다.
    • 임계값은 그레이스케일 범위인 0-255 사이의 정수를 지정할 수 있다.

dst(x, y) = \begin{cases} 255 & src(x, y) > T \\ 0 & else \end{cases}

  • OpenCV에서 이진화는 threshold() 함수를 이용하여 수행할 수 있다.
    • threshold() 함수의 동작은 type 인자에 의해 결정된다. type 인자에는 ThresholdTypes 열거형 상수를 지정할 수 있다.
ThresholdTypes 설명
THRES_BINARY dst(x, y) = \begin{cases} maxval & src(x, y) > thresh \\ 0 & else \end{cases}
THRES_BINARY_INV dst(x, y) = \begin{cases} 0& src(x, y) > thresh \\ maxval & else \end{cases}
THRES_TRUNC dst(x, y) = \begin{cases} thresh & src(x, y) > thresh \\ src(x, y) & else \end{cases}
THRES_TOZERO dst(x, y) = \begin{cases} src(x, y) & src(x, y) > thresh \\ 0 & else \end{cases}
THRES_TOZERO_INV dst(x, y) = \begin{cases} 0 & src(x, y) > thresh \\ src(x, y) & else \end{cases}
THRES_OTSU 오츠(Otsu) 알고리즘을 이용한 자동 임계값 결정
THRES_TRIANGLE 삼각(triangle) 알고리즘을 이용한 자동 임계값 결정
  • threshold() 함수를 이용하여 영상을 이진화하려면 maxval 인자에 255를 지정하고 type 인자에 THRES_BINARY 또는 THRES_BINARY_INV를 지정한다.
    • THRES_BINARY_INV 방법으로 이진화하는 것은 THRES_BINARY 방법으로 이진화를 수행한 후 영상을 반전하는 것과 동일하다.
  • ThresholdTypes 열거형 상수 중 THRES_OTSU와 THRES_TRIANGLE는 임계값을 자동으로 결정할 때 사용한다.
    • 두 방법 모두 픽셀 값 분포를 분석하여 임계값을 자동으로 결정하고, 결정된 임계값을 이용하여 임계값 연산을 수행한다.
    • 두 상수는 보통 논리합 연산자(|)를 이용하여 다른 ThresholdTypes 상수와 함께 사용된다.
  • 자동 이진화를 수행할 경우 threshold() 함수 내부에서 임계값을 자체적으로 계산하여 사용하기 때문에 threshold(0 함수의 세 번째 인자로 전달한 thresh 값은 사용되지 않는다.
    • 자동 임계값 결정 방법은 CV_8UC1 타입의 영상에만 적용할 수 있다.

적응형 이진화

  • threshold() 함수는 지정한 임계값을 영상 전체 픽셀에 동일하게 적용하여 이진화 영상을 생성한다. 이러한 방식을 전역 이진화(global binarization)라고 한다.
    • 그런데 영상의 특성에 따라 적녁 이진화를 어려운 경우가 있는데, (아래 이미지와 같이 불균일한 조명 환경에서 촬영된 영상의 경우) 이런 경우 각 픽셀마다 서로 다른 임계값을 사용하는 적응형 이진화(adaptive binarization)기법을 사용하는 것이 효과적이다.

  • 적응형 이진화는 영상의 모든 픽셀에서 정해진 크기의 사각형 블록 영역을 설정하고, 블록 영역 내부의 픽셀 값 분포로부터 고유의 임계값을 결정하여 이진화하는 방식이다.
    • 이때 (x, y) 좌표에서의 임계값 T(x, y)는 다음 수식을 이용하여 결정한다.

T(x, y) = \mu(x, y) - C

  • 위 수식에서 \mu(x, y) 는 (x, y) 주변 블록 영역의 픽셀값 평균이고 C는 임계값의 크기를 조정하는 상수이다.
    • 블록 내부 픽셀 값의 평균 \mu(x, y) 는 일반적인 산술 평균을 사용하거나 또는 가수이산 함수 형태의 가중치를 적용한 가중 평균을 사용한다.
    • 상수 C는 영상의 특성에 따라 사용자가 결정한다.
  • OpenCV에서 적응형 이진화는 adpativeThreshold() 함수를 이용하여 수행할 수 있다.
    • adpativeThreshold() 함수는 각 픽셀 주변의 blocksize x blocksize 영역에서 평균을 구하고, 평균에서 상수 C를 뺀 값을 해당 픽셀의 임계값으로 사용한다.
    • 이때 블록 영역의 평균을 구하는 방식은 adaptiveMethod 인자를 통해 설정할 수 있다. adaptiveMethod 인자에 ADAPTIVE_THRESH_MEAN_C를 지정하면 산술평균을 이용하고, ADAPTIVE_THRESH_GAUSSIAN_C를 지정하면 가우시안 마스크를 적용하여 가우시안 가중 평균을 계산한다.

모폴로지 연산

이진 영상의 침식과 팽창

  • 모폴로지(morphology)는 형태 또는 모양에 관한 학문을 의미하는데, 영상 처리 분야에서 모폴로지는 객체의 형태 및 구조에 대해 분석하고 처리하는 기법을 의미하며, 수학적 모폴로지(mathematical morphology)라고도 한다.
    • 모폴로지 기법은 그레이스케일 영상과 이진 영상에 대해 모두 적용할 수 있지만 주로 이진 영상에서 객체의 모양을 단순화시키거나 잡음을 제거하는 용도로 사용된다.
  • 모폴로지 연산을 정의하려면 먼저 구조 요소(structuring element)를 정의해야 한다.
    • 구조 요소는 마치 필터링에서 사용되는 마스크처럼 모폴로지 연산의 동작을 결정하는 작은 크기의 행렬이다.
    • 구조 요소는 다양한 크기와 모양으로 정의할 수 있으며, 다양한 구조 요소의 예를 아래 그림에 표현하였다.
    • 필요에 따라 원하는 구조 요소를 선택하여 사용할 수도 있지만, 대부분의 모폴로지 연산에서는 아래 그림의 4번째에 있는 3×3 정방형 구조요소를 사용한다.

  • 영상의 모폴로지 기법 중 가장 기본이 되는 연산은 침식(erosion)과 팽창(dilation)이다.
    • 침식 연산은 객체 영역의 외곽을 골고루 깎아내는 연산으로 전체적으로 객체 영역은 축소되고 배경 영역은 확대된다.
    • 침식 연산은 구조 요소를 영상 전체에 대해 스캔하면서, 구조 요소가 객체 영역 내부에 완전히 포한될 경우 고정점 위치 픽셀을 255로 설정한다.
    • 이진 영상의 팽창 연산은 객체 외곽을 확대하는 연산이다. 팽창 연산을 수행하면 객체 영역은 확대되고, 배경 영역은 줄어든다.
    • 팽창 연산은 구조 요소를 영상 전체에 대해 이동시키면서, 구조 요소와 객체 영역이 한 픽셀이라도 만날 경우 고정점 위치 픽셀을 255로 설정한다.
  • 작은 크기의 영상에서 3×3 정방형 구조요소를 이용하여 침식과 팽창 연산을 수행한 예시가 아래 그림과 같다.
    • (a)는 12×12 크기의 입력 이진 영상을 확대하여 나타낸 것이며, 이 영상에는 흰색으로 표시된 객체가 하나 있다. (b)는 3×3 정방형 구조 요소이다.
    • (a) 영상에 대해 (b) 구조 요소를 이용하여 침식 연산을 수행한 결과가 (c)에 나타났다. 침식 연산에 의해 객체 모양이 상하좌우 모든 방향에 대해 1픽셀 정도 깎인 것 같이 변경되었다. 특히 객체 윗 부분의 튀어나온 부분은 매끈하게 제거 되었다.
    • (a) 영상에 팽창 연산을 수행한 결과는 (d)와 같다. 객체 영역이 상하좌우 모든 방향에 대해 1픽셀 정도 확대된 것 같이 변경되었다. 특히 객체 하단의 패인 부분이 깔끔하게 매워졌다.

  • OpenCV에서 구조 요소는 원소 값이 0 또는 1로 구성된 CV_8UC1 타입의 Mat 행렬로 표현된다.
    • 구조 요소 행렬에서 값이 1인 원소만을 이용하여 구조 요소의 모양을 결정한다.
    • OpenCV는 널리 사용되는 모양의 구조 요소 행렬을 간단하게 생성할 수 있도록 getStructuringElement() 함수를 제공한다.
  • getStructuringElement() 함수는 지정한 모양과 크기에 해당하는 구조 요소 행렬을 반환한다.
    • shape은 구조 요소의 모양을 결정하는 역할을 하며, MorphShapes 열거형 상수 중 하나를 지정할 수 있다.
    • 구조 요소의 크기는 ksize 인자를 통해 지정하며, 보통 가로와 세로 크기를 모두 홀수로 지정한다.
MorphShapes 설명
MORPH_RECT 사각형 모양의 구조 요소
MORPH_CROSS 십자가 모양의 구조 요소
MORPH_ELLIPSE 타원 모양의 구조 요소. 지정한 구조 요소 크기의 사각형에 내접하는 타원을 이용한다.
  • OpenCV에서 영상의 침식연산은 erode() 함수를 이용하여 수행한다.
    • erode() 함수의 kernel에는 getStructuringElement() 함수로 생성한 구조 요소 행렬을 지정할 수 있다.
    • 다만 kernel 인자에 Mat() 또는 noArray()를 지정하면 3×3 정방형 구조 요소를 사용하여 침식 연산을 수행한다.
  • OpenCV에서 영상의 팽창 연산을 수행하려면 dilate() 함수를 사용한다.
    • dilate() 함수의 인자 구성과 사용법은 erode와 동일하다.
void erode_dilate()
{
Mat src = imread("milkdrop.bmp", IMREAD_GRAYSCALE);

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Mat bin;
threshold(src, bin, 0, 255, THRESH_BINARY | THRESH_OTSU);

Mat dst1, dst2;
erode(bin, dst1, Mat());
dilate(bin, dst2, Mat());

imshow("src", src);
imshow("bin", bin);
imshow("erode", dst1);
imshow("dilate", dst2);

waitKey();
destroyAllWindows();
}

이진 영상의 열기와 닫기

  • 모폴로지 기법 중 열기(opening)과 닫기(closing) 연산은 침식과 팽창을 이용하여 구현할 수 있는 연산이다.
    • 열기 연산은 입력 영상에 대해 침식 연산을 먼저 수행한 후 그 다음에 팽창 연산을 수행하는 연산이며, 닫기 연산은 팽창 연산을 먼저 수행한 후 그 다음에 침식 연산을 수행하는 연산이다.
  • 열기와 닫기 연산은 각각 침식과 팽창이 한 번 씩 적용되기 때문에 객체 영역의 크기가 바뀌지는 않지만 침식과 팽창 연산을 적용하는 순서에 따라 서로 다른 효과가 발생한다.
    • 열기 연산은 침식 연산을 먼저 수행하기 때문에 한두 픽셀짜리 영역이 제거된 후, 팽창 연산이 수행되므로 이진 영상에 존재하는 작은 크기의 객체가 효과적으로 제거된다. 
    • 반면 닫기 연산은 팽창 연산을 먼저 수행하기 때문에 객체 내부의 작은 구멍이 메워지게 된다.
  • 작은 크기의 영상에서 3×3 정방형 구조 요소를 이용하여 열기와 닫기를 수행한 결과는 아래 그림과 같다.
    • (a)는 원본영상이며, (b)는 열기 연산의 결과, (c)는 닫기 연산의 결과이다.

  • OpenCV에서 모폴로지 열기와 닫기 연산은 morphologyEx() 함수를 이용하여 수행할 수 있다.
    • morphologyEx() 함수는 열기와 닫기 뿐만 아니라 침식과 팽창과 같은 일반적인 모폴로지 연산도 수행할 수 있는 범용적인 모폴로지 연산 함수이다.
  • morphologyEx() 함수는 세 번째 연산 인자인 op를 이용하여 모폴로지 연산 방법을 지정한다. op 인자에는 MorphTypes 열거형 상수 중 하나를 지정할 수 있으며, 이진 영상에 대해 자주 사용하는 MorphTypes 상수에 대해 아래 표로 정리하였다.
    • MORPH_GRADIENT 상수는 팽창 결과 영상에서 침식 결과 영상을 빼는 연산을 수행하며 객체의 외곽선이 추출되는 효과가 있다.
MorphTYpes 설명
MORPH_ERODE 침식 연산
MORPH_DILATE 팽창 연산
MORPH_OPEN 열기 연산
MORPH_CLOSE 닫기 연산
MORPH_GRADIENT

모폴로지 그래디언트 계산
dst = dilate(src, element) – erode(src, element)

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 컬러 영상 처리

컬러 영상 다루기

컬러 영상의 픽셀 값 참조

  • OpenCV의 컬러 영상은 기본적으로 RGB 색상 순서가 아니라 BGR 색상 순서로 픽셀값을 표현한다.
    • 컬러 영상에서 각각의 RGB 성분은 0-255 사이의 값을 가질 수 있다.
    • OpenCV에서 각 생상 성분 값은 uchar 자료형을 사용하여 표현한다. 
    • 컬러 영상에서 하나의 픽셀은 세 개의 색상 성분을 가지고 있으므로 컬러 영상의 한 픽셀을 정확히 표현하려면 Vec3b 자료형을 사용해야 한다.
    • Vec3b 클래스는 크기가 3인 uchar 자료형 배열을 멤버로 가진 클래스로 3바이트의 크기를 갖는다.
  • 컬러 영상에서 픽셀 값을 참조할 때는 Mat::at() 함수를 사용한다.
    • Mat::at() 함수는 템플릿으로 정의된 함수이므로 3채널 컬러 영상에 대해 Mat::at() 함수를 사용하려면 Vec3b 자료형을 명시해야 한다.
Vec3b& pixel = img.at<Vec3b>(0, 0);
uchar b1 = pixel[0];
uchar g1 = pixel[1];
uchar r1 = pixel[2];
  • Mat::ptr() 함수를 이용하여 컬러 영상의 특정 행 시작 주소를 얻어 올 때에도 Vec3b 자료형을 명시하여 사용해야 한다.
Vec3b& ptr = img.ptr<Vec3b>(0);
uchar b2 = ptr[0][0];
uchar g2 = ptr[0][1];
uchar r2 = ptr[0][2];

색 공간 변환

  • OpenCV에서는 컬러 영상을 Mat 객체에 저장할 때 BGR 순서로 색 정보를 표현하는데, 이를 RGB 색 모델(color model) 또는 RGB 색 공간(color space) 라고 한다.
  • RGB 색 공간은 널리 쓰이지만 컬러 영상 처리 관점에서는 환영 받지 못하는 편이다. 컬러 영상 처리에서는 보통 색상 구분이 용이한 HSV, HSL 색 공간을 사용하거나 휘도 겅분이 구분되어 있는 YCrCb, YUV 등 다른 색 공간을 사용하는 것이 좋다.
    • 때문에 OpenCV에서는 HSV나 YGrCb 등 다른 색 공간으로 변환하는 인터페이스를 제공한다.
  • OpenCV에서 색 공간을 다른 색 공간으로 변환할 때는 cvtColor() 함수를 사용한다.
    • 아래 표는 주로 사용되는 색 공간 변환 코드이다.
ColorConversionCodes 설명

COLOR_BGR2RGB
COLOR_RGB2RGR 

BRG 채널 순서와 RGB 채널 순서를 상호 변환
COLOR_BGR2GRAY 3채널 BGR 컬러 영상을 1채널 그레이스케일 영상으로 변환
COLOR_GRAY2BGR 1채널 그레이스케일 영상을 3채널 BGR 컬러 영상으로 변환
COLOR_BGR2XYZ BGR 색 공간을 CIE CYZ 색 공간으로 변환
COLOR_XYZ2BGR CIE XYZ 색 공간을 BGR 색 공간으로 변환
COLOR_BGR2YCrCb BGR 색 공간을 YCrCb 색 공간으로 변환
COLOR_YCrCb2BGR YCrCb 색 공간을 BGR 색 공간으로 변환
COLOR_BGR2HSV BGR 색 공간을 HSV 색 공간으로 변환
COLOR_HSV2BGR HSV 색 공간을 BGR 색 공간으로 변환
COLOR_BGR2Lab BGR 색 공간을 CIE Lab 색 공간으로 변환
COLOR_Lab2BGR CIE Lab 색 공간을 BGR 색 공간으로 변환

BGR2GRAY와 GRAY2BGR

  • BGR2GRAY 색 공간 변환 코드는 GBR 컬러 영상을 그레이스케일 영상으로 변환할 때 사용한다.
    • 컬러 영상을 그레이스케일 영상으로 변환하는 이유는 연산 속도와 메모리 사용량을 줄이기 위함
  • BGR 3채널 컬러 영상을 그레이스케일 영상으로 변환할 때는 다음 공식을 사용한다.
    • BGR2GRAY 색 공간 변환 코드에 의해 만들어지는 결과 영상은 CV_8UC1 타입으로 설정된다.

Y = 0.299R + 0.587G + 0.114B

  • 반대로 GRAY2BGR 색 공간 변환 코드는 그레이스케일 영상을 BGR 컬러 영상으로 변환할 때 사용한다. 이 경우 결과 영상은 CV_8UC3 타입으로 결정되고, 각 피셀의 BGR 색상 성분 값은 다음과 같이 결정된다.

R = G = B = Y

BGR2HSV와 HSV2BGR

  • HSV 색 모델은 색상(hue), 채도(saturation), 명도(value)로 색을 표현하는 방식이다.
    • 색상은 빨간색, 노란색, 녹색과 같은 색의 종류를 의미한다.
    • 채도는 색의 순도를 나타내는데, 빨간색에 대해 채도가 높으면 맑은 선홍색이되고, 채도가 낮으면 탁ㅎ나 빨간색으로 보이게 된다.
    • 명도는 빛의 세기를 나타내며, 명도가 높으면 밝고 낮으면 어둡게 보인다.
  • HSV 색 공간은 아래 그림과 같이 원뿔 모양으로 표현할 수 있다.
    • HSV 색 공간 모형에서 색상은 원뿔을 가로로 잘랐을 때 나타나는 원형에서 각도로 정의된다.
    • 각도가 0도에 해당할 때 빨간색을 나타내고, 각도가 증가할 수록 노란색, 녹색, 하늘색, 파란색, 보라색을 거쳐 각도가 360도에 가까워지면 다시 빨간색으로 표현된다.
    • 채도는 원뿔을 가로로 잘랐을 때 나타나는 원 모양의 중심에서 최솟값을 갖고, 원의 중심에서 방사형으로 멀어지는 방향으로 값이 증가한다.
    • 명도는 원뿔 아래쪽 꼭지점에서 최솟값을 갖고 원뿔의 축을 따라 올라가면서 증가한다.

  • OpenCV에서 BGR2HSV 색 공간 변환 코드를 이용하여 8비트 BGR 영상을 HSV 영상으로 변환할 경우 H 값은 0부터 179사이의 정수로 표현되고, S와 V는 0-255 사이의 정수로 표현된다.
    • 색상 값은 0-360도 사이의 각도로 표현하지만 uchar 자료형으로는 256 이상의 정수를 표현할 수 없기 때문에 OpenCV에서는 각도를 2로 나눈 값을 H 성분으로 저정한다.
    • 만약 cvtColor() 함수의 입력 BGR 영상이 0에서 1사이의 값으로 정규화된 CV_32FC3 타입의 행렬이라면 H 값은 0에서 360 사이의 실수로 표현되고 S와 V는 0-1 사이의 실수 값으로 표현된다.

BGR2YCrCb와 YCrCb2BGR

  • YCrCb 색 공간에서 Y 성분은 밝기 또는 휘도(luminance) 정보를 나타내고, Cr과 Cb 성분은 색상 또는 색차(chrominance) 정보를 나타낸다.
    • RGB 색상 성분으로부터 Y 성분을 계산하는 공식은 그레이스케일 계산공식과 완전히 같다.
    • Cr과 Cb 성분은 밝기에 대한 정보는 포함하지 않으며 오직 색상에 대한 정보만 갖고 있다. 그러므로 YCrCb 색공간은 영상을 그레이스케일 정보와 색상 정보로 분리하여 처리할 때 유용하다.
  • OpenCV에서 BGR2YCrCb 색 공간 변환 코드를 이용하여 8비트 BGR 영상을 YCrCb 영상으로 변환할 경우 Y, Cr, Cb 각각의 성분은 0-255 사이의 값으로 표현된다.
    • 만약 cvtColor(0 함수의 입력 영상이 0-1 사이의 값으로 정규화된 CV_32FC3 타입의 행렬이라면 Y, Cr, Cb 각각의 성분 값도 0-1 사이의 실수 값으로 표현된다.
  • Y 성분을 128로 고정한 상태에서 Cr과 Cb 값에 따른 색상 표현은 아래 그림과 같다.
    • HSV 색 공간에서는 H 값만 이용하여 색 종류를 구분할 수 있지만 YCrCb 색 공간에서는 Cr과 Cb를 조합하여 색을 구분할 수 있다.

색상 채널 나누기

  • OpenCV에서는 컬러 영상을 uchar 자료형을 사용하고 세 개의 채널을 갖는 Mat 객체로 표현한다.
    • 그런데 컬러 영상을 다루다 보면 빨간색 성분만 이용하거나 HSV 색 공간으로 변홚나 후 H 성분만을 이용하는 경우가 종종 발생한다. 이러한 경우에는 3채널 Mat 객체를 1채널 Mat 객체 3개로 분리하여 다루는 것이 효율적이다.
  • OpenCV에서 다채널 행렬을 1채널 행렬 여러 개로 변환할 때는 split() 함수를 사용한다.
    • split() 함수와 반대로 1채널 행렬 여러 개를 합쳐서 다채널 행렬 하나를 생성하려면 merge() 함수를 사용한다.

컬러 영상 처리 기법

컬러 히스토그램 평활화

  • OpenCV에서 equalizeHist() 함수를 통해 히스토그램 평활화를 수행할 수 있지만 equalizeHist() 함수는 그레이스케일 영상만 입력 받을 수 있다.
    • 3채널 컬러 영상에 대해 히스토그램 평활화를 수행하려면 OpenCV 함수를 조합하여 직접 구현해야 한다.
  • 일반적으로 컬러 영상의 히스토그램 평활화를 생각해 보면 아래 이미지와 같이 입력 영상을 R, G, B 3개의 채널로 나누고 채널별로 히스토그램 평활화를 수행한 후 이를 다시 합치는 방식을 생각하게 되는데, 이러한 방식은 R, G, B 색상 채널마다 서로 다은 형태의 명암비 변환 함수를 사용하게 됨으로써 원본 영상과 다른 색상의 결과 영상이 만들어지는 문제가 발생한다.

  • 컬러 영사으이 색감은 변경하지 않고 명암비를 높이려면 영상의 밝기 정보만 이용해야 한다.
    • 그러므로 컬러 영상에 대해 히스토그램 평활화를 수행하려면 아래 이미지와 같이 입력 영상을 밝기 정보와 색상 정보로 분리한 후, 밝기 정보에 대해서만 히스토그램 평활화를 수행하면 된다.
    • 영상을 YCrCb 색공간으로 변환하여 Y 성분에 대해서만 히스토그램 평활화를 수행한 후 다시 합치면 된다.

색상 범위 지정에 의한 영역 분할

  • 컬러 영상을 다루는 응용에서 자주 요구되는 기법이 특정 색상 영역을 추출하는 작업이다. 예컨대 영상에서 빨간색 픽셀을 모두 찾아내서 빨간색 객체의 위치와 크기를 알아내는 작업이 있다.
    • 컬러 영상에서 빨간색, 파란색 등의 대표적인 색상 영역을 구분할 때는 HSV와 같은 색상 정보가 따로 설정되어 있는 색 공간을 사용하는 것이 유리하다. 
    • 예컨대 HSV 색 공간에서 녹색은 H 값이 60 근방으로 표현되기 때문에 H값이 60에 가까운지를 조사하여 녹색 픽셀을 찾아낼 수 있다.
  • OpenCV에서 행렬의 원소 값이 특정 범위 안에 있는지 확인하려면 inRange() 함수를 사용하면 된다.
    • inRange() 함수는 입력 영상 src의 픽셀 값이 지정한 밝기 또는 색상 범위에 포함되어 있으면 흰색, 그렇지 않으면 검은색으로 채워진 마스크 영상 dst를 반환한다.
    • 입력 영상 src에는 1채널 행렬과 다채널 행렬을 모두 지정할 수 있다. 만약 그레이스케일 영상을 입력 영상으로 사용할 경우 특정 밝기 값 범위에 있는 픽셀 영역을 추출할 수 있다.
    • 1채널 영상에 대해 inRange() 함수의 동작을 수식으로 표현하면 다음과 같다.

dst(x, y) = \begin{cases} 255 & lowerb(x, y) \leq src(x, y) \leq upperb(x, y) \\ 0 & else \end{cases}

히스토그램 역투영

  • inRange() 함수를 이용하여 색상 영역을 검출하는 방법을 HSV 색 공간에서 H 값을 이용하여 수행하면 간단하게 특정 색상을 골라낼 수 있어서 편리하다.
    • 그러나 이러한 방식은 빨간색, 노란색, 녹색, 파란색처럼 원색에 가까운 색상을 찾기에는 효과적이지만 사람의 피부색처럼 미세한 변화가 있거나 색상 값을 수치적으로 지정하기 어려운 경우에는 적합하지 않다.
  • 만약 입력 영상에서 찾고자 하는 객체의 기준 영상을 미리 가지고 있다면 컬러 히스토그램 정보를 이용하여 비슷한 색상 영역을 찾을 수 있다.
    • 즉 기준 영상으로부터 찾고자 하는 객체의 컬러 히스토그램을 미리 구하고, 주어진 입력 영상에서 해당 히스토그램에 부합하는 영역을 찾아내는 방식이다.
    • 이처럼 주어진 히스토그램 모델과 일치하는 픽셀을 찾아내는 기법을 히스토그램 역투영(histogram backprojection)이라고 한다. 예컨대 피부색에 대한 색상 히스토그램을 갖고 있다면 역투영 방법을 사용하여 영상에서 피부색 영역을 검출할 수 있다.
  • OpenCV에서 히스토그램 역투영은 calcBackProject() 함수를 이용하여 수행할 수 있다.
    • calcBackProject() 함수는 입력 영상에서 히스토그램 hist를 따르는 픽셀을 찾고, 그 정보를 backProject 영상으로 반환한다.
Mat ref, ref_ycrcb, mask;
ref = imread("ref.png", IMREAD_COLOR);
mask = imread("mask.bmp", IMREAD_GRAYSCALE);
cvtColor(ref, ref_ycrcb, COLOR_BGR2YCrCb);

Mat hist;
int channels[] = { 1, 2 };
int cr_bins = 128;
int cb_bins = 128;
int histSize[] = { cr_bins, cb_bins };
float cr_range[] = { 0, 256 };
float cb_range[] = { 0, 256 };
const float* ranges[] = { cr_range, cb_range };

calcHist(&ref_ycrcb, 1, channels, mask, hist, 2, histSize, ranges);

Mat src, src_ycrcb;
src = imread("kids.png", IMREAD_COLOR);
cvtColor(src, src_ycrcb, COLOR_BGR2YCrCb);

Mat backproj;
calcBackProject(&src_ycrcb, 1, channels, hist, backproj, ranges, 1, true);

imshow("src", src);
imshow("backproj", backproj);
waitkey(0);

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 에지 검출과 응용

에지 검출

미분과 그래디언트

  • 영상에서 에지(edge)란 한쪽 방향으로 픽셀 값이 급격하게 바뀌는 부분을 의미
    • 일반적으로 객체와 배경의 경계, 객체와 다른 객체의 경계에서 에지가 발생한다.
    • 그러므로 영상에서 에지를 찾아내는 작업은 객체의 윤곽을 알아낼 수 있는 방법이며, 다양한 컴퓨터 비전 시스템에서 객체 판별을 위한 전처리로 에지 검출이 사용됨.
  • 영상에서 에지를 찾아내려면 픽셀 값의 변화율을 측정하여 변화율이 큰 픽셀을 선택한다.
    • 수학에서 함수 또는 데이터의 변화율을 미분(derivative)라고 한다.

f' = {df \over dx} = lim_{\Delta x \to 0} {f(x + \Delta x) - f(x) \over \Delta x}

  • 1차원 연속 함수 f(x) 의 값 변화에 따른 미분 f'(x) 는 아래 그림과 같다.
    • (미분 내용 설명 생략)

  • 영상은 2차원 평면 위에 픽셀 값이 정형화 되지 않은 상태로 나열되어 있는 형태이므로 미분 공식을 사용할 수 없다.
    • 영상으로부터 미분을 계산하려면 두 가지 특징을 고려해야 하는데, 하나는 영상이 2차원 평면에서 정의된 함수라는 점이고, 두 번째는 영상이 정수 단위 좌표에 픽셀이 나열되어 있는 이산함수라는 점이다.
  • 영상과 같이 일련의 데이터가 순서대로 나열되어 있는 경우에는 미분 근사화 방법을 이용하여 변화량을 측정할 수 있다. 미분 근사는 다음 세 가지 방법을 주로 사용한다.
    • 전진 차분 (forward difference)
      • {dI \over dx} \cong {I(x + h) - I(x) \over h}
    • 후진 차분 (backward difference)
      • {dI \over dx} \cong {I(x) - I(x - h) \over h}
    • 중앙 차분 (centered difference)
      • {dI \over dx} \cong {I(x + h) - I(x - h) \over h}
  • 위 수식에서 I(x) 는 1차원 이산함수이고, h 는 이산 값의 간격을 의미한다.
    • 미분 근사 방법을 영상에 적용할 경우, h 는 픽셀의 간격이라 생각할 수 있으며 보통 픽셀 간격의 최소단위인 1을 h 값으로 사용한다.
    • 즉, 전진 차분 수식은 I(x + 1) - I(x) 로 정리되며, 이는 자기 자신 바로 앞에 있는 픽셀에서 자기 자신 픽셀 값을 뺀 형태이다.
    • 후진 차분은 I(x) - I(x - 1) 로 정리되며, 이는 자기 자신 픽셀에서 바로 뒤에 있는 픽셀 값을 뺀 형태이다.
    • 마지막으로 중앙 차분은 {I(x + 1) - I(x - 1) \over 2} 로 정리되며,로 정리되며 자기 자신을 제외하고 바로 앞과 뒤의 픽셀값을 이용하는 미분 근사 방법이다.
    • 세 가지 방법 중 중간값 차이를 이용하는 방법이 이론적으로 근사화 오류가 가장 적으며, 실제 영상에서 미분을 계산할 때도 널리 쓰이고 있다.
  • 영상은 2차원 평면에서 정의된 함수이기 때문에 영상에서 에지를 찾기 위해서는 영상을 가로 방향과 세로 방향으로 각각 미분해야 한다.
    • 2차원 영상 I(x, y) 를 가로 방향으로 미분한다는 것은 y좌표는 고정한 상태에서 x축 방향으로만 미분 근사를 계산하는 것을 의미하며, 이러한 연산을 x축 방향으로의 편미분(partial derivative)라고 한다.
    • x축 방향의 편미분은 I_{x} 또는 {\partial I \over \partial x} 로 표기한다.
    • 이와 유사하게 y축 방향으로의 편미분은 I_{y} 또는 {\partial I \over \partial y} 라고 표기하고, x 좌표를 고정한 상태에서 y축 방향으로 미분 근사를 수행하여 구할 수 있다.
  • 2차원 영상 I(x, y) 에 대하여 x축과 y축 방향에 대한 각각의 편미분을 중앙 차분 방법으로 근사화하면 다음과 같다.

I_{x} = {\partial I \over \partial x} \cong {((x+1, y) - I(x-1, y) \over 2}

I_{y} = {\partial I \over \partial y} \cong {((x, y+1) - I(x, y-1) \over 2}

  • 중앙 차분을 이용한 영상의 미분 근사는 마스크 연산을 이용하여 쉽게 구현할 수 있다.
    • 2차원 영상을 x축과 y축 방향에 대해 편미분을 수행하는 필터 마스크를 아래 그림에 나타냈다.
    • 왼쪽 그림은 x축 방향으로 편미분하는 필터 마스크이고, 오른쪽 그림은 y축 방향으로 편미분하는 필터 마스크이다.
    • 앞서 설명한 편미분 근사 수식을 그대로 적용하려면 필터 마스크 값에 1/2을 곱해야 하지만, 보통 미분 값의 상대적 크기를 중요시 하기 때문에 단순화 시킨 마스크를 주로 사용한다.
    • 아래 마스크를 이용하여 영상을 각각 필터링하면 영상을 가로 방향과 세로 방향으로 편미분한 정보를 담고 있는 행렬을 얻을 수 있다.

  • 실제 영상에 대해 x축 방향과 y축 방향으로 편미분한 결과는 아래 이미지와 같다.
    • 원래 영상의 미분은 부호가 있는 실수로 계산되지만, 아래 그림은 미분 결과를 시각적으로 분석하기 위해 128을 더한 후 0-255 사이의 정수로 변환하여 그레이스케일 영상 형태로 나타낸 것이다.

  • 2차원 공간에서 정의된 영상에서 에지를 찾으려면 x축 방향과 y축 방향의 편미분을 모두 사용해야 한다.
    • 2차원 공간에서 정의된 함수 f(x, y) 가 있을 때, 이 함수의 x축 방향 미분과 y축 방향 미분을 한꺼번에 벡터로 표현한 것을 그래디언트(gradient)라고 하고 다음과 같이 표기한다.

\nabla f = \left[ \begin{array}{rr} f_{x} \\ f_{y} \end{array} \right] = f_{x}i + f_{y}j

  • 그래디언트는 벡터이기 때문에 크기(magnitude)와 방향(phase) 성분으로 표현할 수 있다.
    • 그래디언트 벡터의 방향은 변화 정도가 가장 큰 방향을 나타내고, 그래디언트 벡터의 크기는 변화율 세기를 나타내는 척도로 생각할 수 있다.
    • 그래디언트 크기는 보통 \| \nabla f \| 로 표기하고 다음과 같이 구한다.

\| \nabla f \| = \sqrt{f_{x}^{2} + f_{y}^{2}}

  • 그래디언트 방향 \theta 는 다음 수식으로 구할 수 있다.

\theta = tan^{-1}({f_{y} \over f_{x}})

  • 그래디언트 벡터의 크기와 방향을 제대로 이해하기 위해 실제 영상에서 그래디언트를 구한 예를 아래 그림에 나타냈다.
    • 아래 그림에 나타난 영상은 어두운 배경에 밝기가 다른 두 개의 객체가 있는 영상이다.
    • 이 영상에서 객체와 배경 경계상의 세 점 a, b, c를 선택하고, 각 점에서의 그래디언트 벡터를 빨간색 화살표로 나타냈다.
    • 빨간색 화살표의 길이는 그래디언트 크기를 나타내고, 화살표 방향은 그래디언트 벡터의 방향을 나타낸다.
    • 그래디언트 벡터의 크기는 밝기 차이가 클수록 크게 나타나므로 점 a, b의 화살표보다 점 c에서 화살표 길이가 더 길게 나타난다.
    • 그래디언트 벡터의 방향은 해당 위치에서 밝기가 가장 밝아지는 방향을 가리킨다.
    • 점 c에 대해서는 특별히 x축 방향으로의 편미분 f_{x} 와 y축 방향으로의 편미분 f_{y} 성분을 함께 표시하였으며, 이 두 성분을 이용하여 빨간색 화살표를 그릴 수 있다.
    • 참고로 아래 그림에서 노란색으로 표시된 화살표는 그래디언트 벡터와 수직인 방향을 표시한 것이며, 이를 에지의 방향이라고 부른다.

  • 2차원 영상에서 에지를 찾는 기본적인 방법은 그래디언트 크기가 특정 값보다 큰 위치를 찾는 것이다.
    • 여기서 에지 여부를 판단하기 위해 기준이 되는 값을 임계값(threshold) 또는 문턱치라고 한다.
    • 임계값은 영상의 특성에 따라 다르게 설정해야 하며, 보통 사용자의 경험에 의해 결정된다.
    • 일반적으로 임계값을 높게 설정하면 밝기 차이가 급격하게 변하는 에지 픽셀만 검출되고, 낮게 설정하면 약한 에지 성분도 검출된다.

마스크 기반 에지 검출

  • 앞서 영상을 x축 방향과 y축 방향으로 편미분 하는 1×3, 3×1 크기의 마스크에 대해 알아봤는데, 대부분의 영상에는 잡음이 포함되어 있어서 1×3 또는 3×1 마스크를 이용하여 미분을 구할 경우 다소 부정확한 결과가 생성될 수 있다.
    • 그러므로 실제 영상에 미분을 구할 때는 잡음의 영향을 줄일 수 있도록 좀 더 큰 크기의 마스크를 이용한다.
    • 여러 방법의 미분 근사 마스크가 개발되었지만, 가장 널리 사용되는 것은 소벨 필터(Sobel filter) 마스크이다.
  • 영상을 가로 방향과 세로 방향으로 미분하는 3×3 크기의 소벨 필터 마스크를 아래 그림에 나타냈다.
    • 아래 그림의 (a)는 x축 방향으로 편미분을 구하는 소벨 마스크이고, (b)는 y축 방향으로 편미분을 구하는 소벨 마스크이다.
    • (a)에 나타난 x축 방향 미분 마스크는 현재 행에 대한 중앙 차분 연산을 2회 수행하고, 이전 행과 다음 행에 대해서도 중앙 차분 연산을 1회씩 수행한다.
    • 이러한 연산은 현재 행과 이웃 행에서의 픽셀 값 변화가 유사하다는 점을 이용하여 잡음의 영향을 줄이기 위함이며, 특히 현재 행에서 두 번의 중앙 차분 연산을 수행하는 것은 현재 행의 중앙 차분 근사에 더 큰 가중치를 주기 위함이다. y축 방향 미분도 같은 방식으로 설계 되었다.

  • OpenCV는 소벨 마스크를 이용하여 영상을 미분하는 Sobel() 함수를 제공한다. Sobel() 함수는 3×3 소벨 마스크 또는 확장된 형태의 큰 마스크를 이용하여 영상을 미분한다.
  • Sobel() 함수는 입력 영상 src를 편미분한 결과를 dst에 저장한다.
    • 결과 영상의 자료형은 ddepth 인자를 통해 명시적으로 지정해야 하고, ddepth에 -1을 지정하면 src와 같은 타입을 사용하는 dst 영상을 생성한다.
    • dx와 dy 인자는 각각 x 방향과 y 방향으로의 편미분 차수를 의미하며, Sobel() 함수에 의해 계산되는 결과 행렬 dst는 다음 수식과 같은 의미를 갖는다.

dst = {\partial^{xorder + yorder} src \over \partial^{xorder} \partial^{xorder}}

  • ksize 이후의 인자는 모두 기본값을 가지고 있으므로 실제 함수 호출시에는 생략할 수 있다.
    • ksize 인자에 1을 지정하면 3×1 또는 1×3 커널을 사용하고, 기본값인 3을 지정하면 3×3 소벨 마스크를 사용한다.
  • Sobel() 함수는 x방향과 y방향으로의 고차 미분을 계산할 수 있지만 대부분의 경우 x방향 또는 y 방향으로의 1차 미분을 구하는 용도로 사용된다.
    • 예컨대 그레이스케일 레나 영상을 x방향으로 편미분한 결과를 dx 행렬에, y방향으로 편미분한 결과를 dy 행렬에 저장하려면 다음과 같이 코드를 작성하면 된다.
Mat src = imread("lenna.bmp", IMREAD_GRAYSCALE);

Mat dx, dy;
Sobel(src, dx, CV_32FC1, 1, 0);
Sobel(src, dy, CV_32FC1, 0, 1);
  • OpenCV는 소벨 마스크 외에도 샤르 필터(Scharr filter) 마스크를 이용한 미분 연산도 지원한다.
    • 샤르 필터는 3×3 소벨 마스크보다 정확한 미분 계산을 수행하는 것으로 알려져 있다.
    • 샤르 필터 마스크는 아래 그림과 같다.

  • 샤르 필터 마스크를 이용하여 영상을 미분하려면 Scharr() 함수를 사용하면 된다.
    • 샤르 필터를 이용한 영상의 미분은 Sobel() 함수를 이용하여 구할 수도 있다. Sobel() 함수의 ksize 인자에 FILTER_SCHARR 또는 -1을 지정하면 3×3 샤르 마스크를 사용하여 영상을 미분한다.
  • Sobel() 또는 Scharr() 함수를 이용하여 x 방향으로 미분과 y 방향으로 미분을 각각 계산하여 행렬에 저장한 후, 두 미분 행렬을 이용하여 그래디언트 크기를 계산할 수 있다.
    • OpenCV는 2차원 벡터의 x 방향 좌표와 y 방향 좌표를 이용하여 벡터의 크기를 계산하는 magnitude() 함수를 제공한다.
    • magnitude() 함수의 입력으로 사용되는 x와 y는 CV_32F 또는 CV_64F 깊이를 사용하는 행렬 또는 벡터여야 한다. magnitude() 함수의 출력 magnitude를 구성하는 원소 값은 다음 수식에 의해 계산된다.

magnitude(I) = \sqrt{x(I)^{2} + y(I)^{2}}

  • 만약 x 방향으로 미분과 y 방향으로 미분이 저장된 두 개의 행렬이 있을 때, 그래디언트의 방향을 계산하고 싶다면 phase() 함수를 사용할 수 있다.
    • phase() 함수에서 x, y는 입력이고 angle은 출력이다. angle의 각 원소는 다음 수식에 의해 계산된다.

angle(I) = atan2({y(I) \over x(I)})

  • Sobel() 함수를 이용하여 영상으로부터 그래디언트를 계산하고, 그래디언트 크기를 이용하여 에지를 검출하는 예제 코드는 아래와 같다.
void sobel_edge()
{
Mat src = imread("lenna.bmp", IMREAD_GRAYSCALE);

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Mat dx, dy;
Sobel(src, dx, CV_32FC1, 1, 0);
Sobel(src, dy, CV_32FC1, 0, 1);

Mat fmag, mag;
magnitude(dx, dy, fmag);
fmag.convertTo(mag, CV_8UC1);

Mat edge = mag > 150;

imshow("src", src);
imshow("mag", mag);
imshow("edge", edge);

waitKey();
destroyAllWindows();
}
  • 아래 이미지는 위 예제 코드를 이용한 결과이다.

캐니 에지 검출기

  • 소벨 마스크 기반 에지 검출은 구현이 간단하고 빠르기 때문에 아직도 많은 컴퓨터 비전 시스템에서 사용되고 있으나, 그래디언트 크기만을 기준으로 에지 픽셀을 검출하기 때문에 임계값에 민감하고 에지 픽셀이 두껍게 표현되는 문제점이 있다.
  • 1986년 캐니(J. Canny)는 에지 검출을 최적화 문제 관점으로 접근함으로써 소벨 에지 검출 방법의 단점을 해결할 수 있는 방법을 제시하였다. 캐니는 자신의 논문에서 다음 세 가지 항목을 좋은 에지 검출기의 조건으로 제시하였다.
    • 정확한 검출(good detection): 에지를 검출하지 못하거나 에지가 아닌데 에지로 검출하는 확률을 최소화해야 한다.
    • 정확한 위치(good localization): 실제 에지의 중심을 찾아야 한다.
    • 단일 에지(single edge): 하나의 에지는 하나의 점으로 표현되어야 한다.
  • 캐니는 이러한 조건을 만족하는 새로운 형태의 에지 검출방법을 제시하였으며 이를 캐니 에지 검출기(canny edge detector)라고 한다.
    • 소벨 에지 검출 방법이 단순히 그래디언트 크기만을 이용하여 에지를 찾는 방법이라면, 캐니 에지 검출기는 그래디언트의 크기와 방향을 모두 고려하여 좀 더 정확한 에지 위치를 찾을 수 있다.
    • 또한 에지는 서로 연결되어 있는 가능성이 높다는 점을 고려하여 그래디언트 크기가 다소 약하게 나타나는 에지도 놓치지 않고 찾을 수 있다.
    • 캐니 에지 검출기는 내부적으로 네 개의 연산 과정을 포함한다. 이 네 가지 연산 과정은 아래 이미지에 정리되어 있다.

가우시안 필터링

  • 캐니 에지 검출기의 첫 번째 과정은 가우시안 필터링이다.
    • 캐니 에지 검출기의 첫 번째 단계에서 가우시안 필터를 적용하는 이유는 영상에 포함된 잡음을 제거하기 위함이다.
    • 가우시안 필터링에 의해 영상이 부드러워지면서 에지의 세기도 함께 감소할 수 있기 때문에 적절한 표준편차를 선택하여 가우시안 필터링을 수행해야 한다.
    • 영상에 포함된 잡음이 심하지 않다면 가우시안 필터링은 생략할 수 있다.

그래디언트 계산

  • 캐니 에지 검출기의 두 번째 과정은 영상의 그래디언트를 구하는 작업이다. 캐니 에지 검출기에서 그래디언트 계산은 보통 3×3 소벨 마스크를 사용한다.
    • 다만 앞서 설명한 소벨 마스크가 그래디언트 크기만 고려했다면 캐니 에지 검출기는 그래디언트 방향도 함께 고려한다.
    • 그러므로 가로 방향과 세로 방향으로 각각 소벨 마스크 필터링을 수행한 후, 그래디언트 크기와 방향을 모두 계산해야 한다.
  • 2차원 공간에서 정의된 함수 f(x, y) 의 그래디언트를 \| \nabla f \| = f_{x}i + f_{y}j 라고 할 경우,
    • 그래디언트 크기는 \| \nabla f \| = \sqrt{f_{x}^{2} + f_{y}^{2}} 로 정의되고 이를 벡터 \nabla f 의 L2 노름(L2 norm)이라고 한다.
    • 그러나 그래디언트 크기를 실제로 계산할 때는 연산 속도 향상을 위해 그래디언트 크기를 \| \nabla f \| \approx |f_{x}| + |f_{y}| 형태로 계산하기도 하며, 이를 벡터 \nabla f 의 L1 노름(L1 norm)이라고 한다.
    • 실제로 OpenCV 에 구현되어 있는 캐니 에지 검출기에서도 그래디언트 크기 계산시 기본적으로 L1 노름을 사용한다.

비최대 억제

  • 에지 검출을 위해 단순히 그래디언트 크기가 특정 임계값보다 큰 픽셀을 선택할 경우, 에지 근방의 여러 픽셀이 한꺼번에 에지로 선택될 수 있다. 에지가 두껍게 표현되는 현상을 방지하기 위해 캐니 에지 검출기에서는 비최대 억제(non-maximum suppression) 과정을 사용한다.
    • 비최대 억제는 그래디언트 크기가 국지적 최대(local maximum)인 픽셀만을 에지 픽셀로 설정하는 기법이다. 상대적으로 국지적 최대가 아닌 픽셀은 에지 픽셀에서 제외하기 때문에 비최대 억제라는 용어를 사용한다.
  • 일반적인 2차원 영상에서 국지적 최대를 찾으려면 특정 픽셀을 둘러싸고 있는 모든 픽셀 값을 검사하여 국지적 최대인지 판별해야 한다.
    • 그러나 캐니 에지 검출기의 비최대 억제 과정에서는 그래디언트 벡터의 방향과 같은 방향에 있는 인접 픽셀끼리만 국지적 최대 검사를 수행한다. 결과적으로 비최대 억제를 수행함으로써 가장 변화율이 큰 위치의 픽셀만 에지로 검색된다.

이중 임계값을 이용한 히스테리시스 에지 트래킹

  • 소벨 에지 검출 방법에서는 그래디언트 크기가 특정 임계값보다 크면 에지 픽셀로, 작으면 에지가 아닌 픽셀로 판단했다. 이 경우 조명이 조금 바뀌거나 임계값을 조금만 조절해도 에지 픽셀 판단 결과가 크게 달라질 수 있다.
    • 즉, 하나의 임계값을 사용할 경우 이분법으로 결과가 판단되기 때문에 환경 변화에 민감해질 수 있다. 이러한 문제를 보완하기 위해 캐니 에지 검출기에서는 두 개의 임계값을 사용한다.
  • 캐니 에지 검출기에서 사용하는 두 개의 임계값 중에서 높은 임계값을 T_{High} , 낮은 임계값을 T_{Low} 라고 할 때,
    • 만약 그래디언트 크기가 T_{High} 보다 크면 이 픽셀은 최종적으로 에지로 판단하고 그래디언트 크기가 T_{Low} 보다 작으면 에지 픽셀이 아니라고 판단한다.
    • 그래디언트 크기가 T_{High} T_{Low} 사이인 픽셀은 에지일 수도 있고 에지가 아닐 수도 있다고 판단하며, 이런 픽셀에 대해서는 추가적인 검사를 수행한다.
  • 그래디언트 크기가 T_{High} 보다 큰 픽셀을 강한 에지(strong edge)라고 표현하고, T_{High} T_{Low} 사이인 픽셀을 약한 에지(weak edge) 라고 할 때,
    • 캐니 에지 검출기의 마지막 단계에서는 히스테리시스 에지 트래킹(hysteresis edge tracking) 방법을 사용하여 약한 에지 중에서 최종적으로 에지로 판별할 픽셀을 선택한다.
    • 히스테리시스 에지 트래킹 방법은 에지 픽셀이 대체로 상호 연결되어 있다는 점을 이용한다.
    • 만약 약한 에지 픽셀이 강한 에지 픽셀과 서로 연결되어 있다면 이 픽셀은 최종적으로 에지로 판단하고, 강한 에지와 연결되어 있지 않은 픽셀은 최종적으로 에지가 아니라고 판단한다.
    • 아래 그림에서 세 가지 형태의 에지 후보 중 (a)는 강한 에지와 연결되어 있으므로 모든 픽셀이 에지로 판별되고, (b)는 강한 에지와 연결이 없으므로 에지로 판별하지 않는다. (c)는 강한 에지와 연결되어 있으므로 에지로 판별하지만 T_{Low} 보다 작은 픽셀은 에지로 판별하지 않는다.

  • OpenCV에서 캐니 알고리즘 검출은 Canny() 함수에 구현되어 있다.
    • Canny() 함수는 두 가지 형태로 정의되어 있는데, 하나는 일반 영상을 입력으로 전달하여 에지를 검출할 때 사용하고, 다른 하나는 이미 x 방향과 y 방향의 미분 영상을 가지고 있을 때 사용한다.
    • Canny() 함수를 사용할 때는 두 개의 임계값을 적절하게 지정하는 것이 중요하다.
    • threshold1과 threshold2 인자에 지정하는 두 개의 임계값은 캐니 에지 검출기의 히스테리시스 에지 트래킹 단계에서 사용된다.
    • 보통 threshold1 인자에는 낮은 임계값을 지정하고 threshold2 인자에는 높은 임계값을 지정한다. 낮은 임계값과 높은 임계값은 보통 1:2 또는 1:3의 비율로 지정한다.
  • Canny() 함수를 사용하여 에지를 검출하는 예제는 아래와 같다.
void canny_edge()
{
Mat src = imread("lenna.bmp", IMREAD_GRAYSCALE);

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Mat dst1, dst2;
Canny(src, dst1, 50, 100);
Canny(src, dst2, 50, 150);

imshow("src", src);
imshow("dst1", dst1);
imshow("dst2", dst2);

waitKey();
destroyAllWindows();
}

직선 검출과 원 검출

허프 변환 직선 검출

  • 직선은 영상에서 찾을 수 있는 많은 특징 중 하나이며 영상을 분석함에 있어 중요한 정보를 제공한다. 
    • 영상에서 직선 성분을 찾기 위해서는 우선 에지를 찾아내고, 에지 픽셀들이 일직선 상에 배열되어 있는지를 확인해야 한다.
  • 영상에서 직선을 찾기 위한 용도로 허프 변환(hough transform) 기법이 널리 사용된다.
    • 허프 변환은 2차원 xy 좌표에서 직선의 방정식을 파라미터(parameter) 공간으로 변환하여 직선을 찾는 알고리즘이다.
  • 일반적인 2차원 평면에서 직선의 방정식은 다음과 같이 나타낼 수 있다.

y = ax + b

  • 이 수식에서 a는 기울기(slope)이고, b는 y 절편(y intersection)이다. 이 직선의 방정식은 다음과 같이 바꿔 쓸 수 있다.

b = -xa + y

  • 방정식을 위와 같이 변경하면 마치 ab 좌표 공간에서 기울기가 -x이고 b절편이 y인 직선의 방정식처럼 보인다.
    • 이처럼 xy 공간에서 직선의 방정식을 ab 공간으로 변경하면 재미있는 현상을 발견할 수 있는데, xy 공간에서 직선은 ab 공간에서 한 점으로 표현되고, 반대로 xy 공간에서 한 점은 ab 공간에서 직선의 형태로 나타난다는 점이다.
  • 아래 이미지에서 (a)의 파란색 실선은 xy 공간에 정의된 직선 y = a_{0}x + b_{0} 이다.
    • 이 수식에서 a_{0} b_{0} 는 직선의 모양을 결정하는 상수이다.
    • xy 공간에서 직선상의 한 점 (x_{0}, y_{0}) 를 선택하고 이 점의 좌표를 이용하면 ab 공간에서 빨간색 직선 b = -x_{0}a + y_{0} 를 정의할 수 있다.
    • 마찬가지로 xy 공간에서 직선상의 다른 한 점 (x_{1}, y_{1}) 를 이용하면 ab 공간에서 보라색 직선 b = -x_{1}a + y_{1} 를 표현할 수 있다.
    • 이 경우 ab 공간에서 빨간색 직선과 보라색 직선이 서로 교차하는 점의 좌표는 (a_{0}, b_{0}) 이며 이는 xy 공간에서 직선의 방정식 y = a_{0}x + b_{0} 를 정의하는 두 개의 파라미터로 구성된 좌표이다.
    • 즉, xy 공간에서 파란색 직선상의 점을 이용하여 생성한 ab 공간상의 직선들은 모두 (a_{0}, b_{0}) 점을 지나간다.

  • 허프 변환을 이용해서 직선의 방정식을 찾으려면 xy 공간에서 에지로 판별된 모든 점을 이용하여 ab 파라미터 공간에 직선을 표현하고, 직선이 많이 교차되는 좌표를 모두 찾아야 한다.
    • 이때 직선이 많이 교차하는 점을 찾기 위해 보통 축적 배열(accumulation array)를 사용한다.
    • 축적 배열은 0으로 초기화된 2차원 배열에서 직선이 지나가는 위치의 배열 원소 값을 1씩 증가시켜 생성한다.
  • 아래 그림은 허프 변환에서 축적 배열을 구축하는 방법을 보여준다.
    • 그림의 왼쪽 xy 영상 좌표계에서 직선 위 세 개의 점을 선택하였고, 각 점에 대응되는 ab 파라미터 공간에서의 직선을 오른쪽 배열 위에 나타냈다. 그리고 배열 위에서 직선이 지나가는 위치의 원소 값을 1씩 증가시킨 결과를 숫자로 나타냈다.
    • 그림의 오른쪽에 나타난 배열이 축적 배열이며, 축적 배열에서 최댓값을 갖는 위치에 해당하는 ab와 b 값이 xy 공간에 있는 파란색 직선의 방정식 파라미터이다.
    • 아래 그림은 하나의 직선에 대해 허프 변환의 예를 설명하였으며, 여러 개의 직선이 존재하는 영상이라면 축적 배열에서 여러 개의 국지적 최댓값을 찾아서 직선의 방정식 파라미터를 결정할 수 있다.

  • 그러나 y = ax + b 형태의 직선의 방정식을 사용할 경우 모든 형태의 직선을 표현하기 어렵다는 잔덤이 있다.
    • 대표적으로 y = ax + b 수식은 y축과 평행한 수직선을 표현할 수 없다. 수직선을 표현하려면 기울기가 무한대가 되어야 하기 때문이다.
    • 그러므로 실제 허프 변환을 구할 때는 다음과 같이 극좌표계 형식의 직선의 방정식을 사용한다.

x cos \theta + y sin \theta = \rho

  • 이 수식에서 \rho 는 원점에서 직선까지의 수직 거리를 나타내고 \theta 는 원점에서 직선에 수직선을 내렸을 때 x축과 이루는 각도를 의미한다. 
    • 이 경우 xy 공간에서 한 점은 \rho \theta 공간에서는 삼각함수 그래프 형태의 곡선으로 표현되고 \rho \theta 공간에서 한 점은 xy 공간에서 직선으로 나타나게 된다.
    • 극좌표계 형식의 직선의 방정식을 사용하여 허프 변환을 수행할 경우에도 축적 배열을 사용하고, 축적 배열에서 국지적 최댓값이 발생하는 위치에서의 \rho \theta 값을 찾아 직선의 방정식을 구할 수 있다.
  • 극좌표계 직선의 방정식을 이용한 허프 변환 직선 검출 과정은 아래 그림과 같다.
    • (a)는 입력 영상이 사용하는 2차원 xy 좌표계이며, 파란색 직선은 x cos \theta_{0} + y sin \theta_{0} = \rho_{0} 이다.
    • 이 직선 위의 세 점을 선택하고 각 점에 대응하는 \rho \theta 공간에서의 곡선을 (b)에 나타냈다.
    • \rho \theta 공간에서 세곡선은 모두 하나의 점에서 교차하며, 이 점의 좌표 (\rho_{0}, \theta_{0}) 가 (a)의 파란색 직선을 나타내는 파라미터가 된다.

  • \rho \theta 는 실수 값을 가지기 때문에 C/C++ 코드로 축적 배열을 구현하려면, \rho \theta 가 가질 수 있는 값의 범위를 적당한 크기로 나눠서 저장하는 양자화 (quantization) 과정을 거쳐야 한다.
    • 예컨대 \theta 는 0에서 \pi 사이의 실수를 가질 수 있는데, 이 구간을 180단계나 360단계로 나눌 수 있다. 구간을 촘촘하게 나눌 경우 정밀한 직선 검출이 가능하지만 연산 시간이 늘어날 수 있다.
  • OpenCV에서는 HoughLines() 함수를 사용하여 허프 변환 직선 검출을 수행할 수 있다.
    • HoughLines() 함수의 첫 번째 인자 image에는 보통 Canny() 함수 등을 이용하여 구한 에지 영상을 지정한다.
    • HoughLines() 함수는 image 영상에서 0이 아닌 픽셀을 이용하여 축적 배열을 구성한다.
    • 직선 파라미터 정보를 받아 올 lines 인자에는 보통 vector<Vec2f> 또는 vector<Vec3f> 자료형의 변수를 지정한다. vector<Vec2f> 자료형을 사용할 경우 \rho \theta 값이 저장되고, vector<Vec3f> 자료형을 사용할 경우 \rho \theta 값 외에 축적 배열에서의 누적 값을 함께 얻어 올 수 있다.
    • rho와 theta 인자는 \rho \theta 값의 해상도를 조정하는 용도로 사용된다. 예컨대 rho에 1을 지정하면 \rho 값을 1픽셀 단위로 설정하며, theta에 CV_PI / 180 을 지정하면 \theta 를 1도 단위로 구분한다. 결국 rho와 theta는 HoughLines() 함수 내부에서 사용할 축적 배열의 크기를 결정하는 역할을 한다.
    • threshold 인자에는 축적 배열에서 직선으로 판단할 임계값을 지정하며, 이 값이 작으면 더 많은 직선이 검출된다.
void hough_lines()
{
Mat src = imread("building.jpg", IMREAD_GRAYSCALE);

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Mat edge;
Canny(src, edge, 50, 150);

vector<Vec2f> lines;
HoughLines(edge, lines, 1, CV_PI / 180, 250);

// 이하 결과를 화면에 출력하는 코드 생략
}

  • OpenCV는 기본적인 허프 변환 직선 검출 방법 외에 확률적 허프 변환(probabilistic Hough transform)에 의한 직선 검출 방법도 제공한다.
    • 확률적 허프 변환 방법은 직선의 방정식 파라미터 \rho \theta 를 반환하는 것이 아니라 직선의 시작점과 끝점 좌표를 반환한다.
    • 즉, 확률적 허프 변환 방법은 선분을 찾는 방법이다.
  • OpenCV에서 확률적 허프 변환 방법은 HoughLinesP() 함수에 구현되어 있다.
    • HoughLinesP() 함수에서 검출된 선분 정보가 저장되는 lines 인자에는 보통 vector<Vec4i> 자료형의 변수를 지정한다. 각각의 선분 정보는 Vec4i 자료형으로 저장되고 하나의 Vec4i 객체에는 선분의 시작 x, y, 끝 x, y 점이 저장된다.
    • rho, theta, threshold 인자의 의미는 HoughLines()와 동일하다.
    • maxLineGap 인자는 일직선상의 직선이 잡암 등 영향으로 끊어져 있을 때 두 직선을 하나의 직선으로 간주하고자 할 때 사용한다.
void hough_lines_segments()
{
Mat src = imread("building.jpg", IMREAD_GRAYSCALE);

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Mat edge;
Canny(src, edge, 50, 150);

vector<Vec4i> lines;
HoughLinesP(edge, lines, 1, CV_PI / 180, 160, 50, 5);

// 이하 결과를 화면에 출력하는 코드 생략
}

허프 변환 원 검출

  • 중심 좌표가 (a, b)이고 반지름이 r인 원의 방정식은 다음과 같다.

(x - a)^{2} + (y - b)^{2} = r^{2}

  • 원의 방정식은 세 개의 파라미터를 가지고 있으므로 허프 변환을 그대로 적용하려면 3차원 파라미터 공간에서 축적 배열을 정의하고 가장 누적이 많은 위치를 찾아야 하는데, 이는 너무 많은 메모리와 연산 시간이 필요로 되므로 OpenCV에서는 일반적인 허프 변환 대신 허프 그래디언트 방법(Hough gradient method)을 사용하여 원을 검출한다.
  • 허프 그래디언트 방법은 두 가지로 구성된다.
    • 첫 번째 단계에서는 영상에 존재하는 모든 원의 중심 좌표를 찾고, 두 번째 단계에서는 검출된 원의 중심으로부터 원에 적합한 반지름을 구한다.
    • 원의 중심 좌표를 찾는 과정에서 축적 배열이 사용된다. 다만 허프 그래디언트 방법에서 사용하는 축적 배열은 파라미터 공간에서 만드는 것이 아니라 입력 영상과 동일한 xy 좌표 공간에서 2차원 배열로 만든다.
    • 원의 중심을 찾기 위해 허프 그래디언트 방법은 입력 영상의 모든 에지 픽셀에서 그래디언트를 구하고, 그래디언트 방향을 따르는 직선상의 축적 배열 값을 1씩 증가 시킨다.
  • 허프 그래디언트 방법을 이용하여 원의 중심을 검출하는 과정은 아래 그림과 같다.
    • 원주 상의 모든 점에 대해 그래디언트 방향의 직선을 그리고, 직선 상의 축적 배열 값을 증가 시키면 결과적으로 원의 중심 위치에서 축적 배열 값이 크게 나타나게 된다.
    • 일단 원의 중심을 찾은 후에는 다양한 반지름의 원에 대해 원주 상에 충분히 많은 에지 픽셀이 존재하는지 확인하여 적절한 반지름을 선택한다.

  • OpenCV에서는 HoughCircles() 함수를 사용하여 원을 검출할 수 있다.
    • HoughCircles() 함수의 첫 번째 인자에는 원본 그레이스케일 입력 영상을 전달한다.
    • 직선을 검출하는 HoughLines()와 HoughLinesP() 함수에서는 입력 영상으로 에지 영상을 전달하였지만, HoughCircles() 함수의 입력 영상에는 에지 영상이 아닌 원본 그레이스케일 영상을 전달하면 함수 내부에서 Sobel() 함수와 Canny() 함수를 이용하여 그레디언트와 에지 영상을 계산한 후 허프 그래디언트 방법으로 원을 검출한다.
    • HoughCircles() 함수의 circles 인자에는 vector<Vec3f> 또는 vector<Vec4f> 자료형의 변수를 지정하는데, vector<Vec3f> 자료형을 사용하면 원의 중심 좌표가 (a, b)와 반지름 r이 차례대로 저장되고, vector<Vec4f> 자료형을 사용할 경우 추가적으로 축적 배열 누적 값이 저장된다.
    • dp 인자는 사용할 축적 배열의 크기를 결정하는 용도로 사용된다. 만약 dp 인자를 1로 지정하면 입력 영상과 같은 크기의 축적 배열을 사용하고, 2를 지정하면 입력 영상의 가로와 세로 크기를 2로 나눈 크기의 축적 배열을 사용한다.
    • minDist 인자에는 인접한 원의 최소 거리를 지정한다. 즉, 두 원의 중심점 사이 거리가 minDist 보다 작으면 두 원 중 하나는 검출하지 않는다.
    • param1 인자는 HoughCircles() 함수 내부에서 캐니 에지 검출기를 이용할 때 높은 임계값으로 사용된다. 캐니 에지 검출기의 낮은 임계값은 param1의 절반으로 설정한다. param2는 축적 배열에서 원의 중심을 찾을 때 사용하는 임계값이다.
    • minRadius와 maxRadius 인자에는 검출할 원의 최소 반지름과 최대 반지름을 지정한다. 만약 영상에서 검출할 원의 대략적인 크기를 알고 있다면 minRadius와 maxRadius 를 지정하여 연산 속도를 향상시킬 수 있다.
void hough_circles()
{
Mat src = imread("coins.png", IMREAD_GRAYSCALE);

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Mat blurred;
blur(src, blurred, Size(3, 3));

vector<Vec3f> circles;
HoughCircles(blurred, circles, HOUGH_GRADIENT, 1, 50, 150, 30);

// 이하 결과를 화면에 출력하는 코드 생략
}

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 영상의 기하학적 변환

어파인 변환

  • 영상의 기하학적 변환(geometric transform)은 영상을 구성하는 픽셀의 배치 구조를 변경함으로써 전체 영상의 모양을 바꾸는 작업. 
  • 입력 영상에서 (x, y) 좌표의 픽셀을 결과 영상의 (x', y') 좌표로 변환하는 방법은 다음과 같이 고유의 함수 형태로 나타낼 수 있다.

\begin{cases} x' = f_{1}(x, y) \\ y' = f_{2}(x, y) \end{cases}

어파인 변환

  • 영상의 기하학적 변환 중에서 어파인 변환(affine transformation)은 영상을 평행 이동시키거나 회전, 크기 변환 등을 통해 만들 수 있는 변환을 통칭한다.
    • 또는 영상을 한쪽 방향으로 밀어서 만든 것 같은 전단 변환도 어파인 변환에 포함된다.
    • 영상에 어파인 변환을 적용할 경우 직선은 그대로 직선으로 나타나고, 직선 간의 길이 비율과 평행 관계가 그대로 유지된다.

  • 어파인 변환은 모두 여섯 개의 파라미터를 이용한 수식으로 정의할 수 있다.

\begin{cases} x' = f_{1}(x, y) = ax + by + c \\ y' = f_{2}(x, y) = dx + ey + f \end{cases}

  • 위 수식은 행렬을 이용해서 다음과 같은 수식으로 표현할 수도 있다.

\left( \begin{array}{rr} x' \\ y'  \end{array} \right) = \left( \begin{array}{rr} a & b \\ d & e  \end{array} \right) \left( \begin{array}{rr} x \\ y  \end{array} \right) + \left( \begin{array}{rr} c \\ f  \end{array} \right)

  • 즉 입력 영상의 좌표를 나타내는 행렬 \left( \begin{array}{rr} x \\ y  \end{array} \right)  앞에 2×2 행렬 \left( \begin{array}{rr} a & b \\ d & e  \end{array} \right) 을 곱하고 그 뒤에 2×1 행렬 \left( \begin{array}{rr} c \\ f  \end{array} \right) 를 더하는 형태로 어파인 변환을 표현한다.
  • 수학적 편의를 위해 입력 영상의 좌표 (x, y) 에 가상의 좌표 1을 하나 추가하여 (x, y, 1) 형태로 바꾸면, 행렬 수식을 다음과 같은 하나의 행렬 곱셈 형태로 바꿀 수 있다.

\left( \begin{array}{rr} x' \\ y'  \end{array} \right) = \left( \begin{array}{rrr} a & b & c \\ d & e & f \end{array} \right) \left( \begin{array}{rrr} x \\ y \\ 1 \end{array} \right)

  • 위 수식에서 여섯 개의 파라미터로 구성된 2×3 행렬 \left( \begin{array}{rrr} a & b & c \\ d & e & f \end{array} \right) 를 어파인 변환 행렬 (affine transform matrix)이라고 한다. 즉 어파인 변환은 2×3 실수형 행렬 하나로 표현할 수 있는 것이다.
  • 입력 영상과 어파인 변환 결과 영상으로부터 어파인 변환 행렬을 구하기 위해서는 최소 세 점의 이동 관계를 알아야 한다.
    • 점 하나의 이동 관계로부터 x좌표와 y좌표에 대한 변환 수식 두 개를 얻을 수 있으므로, 점 세 개의 이동 관계로부터 총 여섯 개의 방정식을 구할 수 있다.
    • 그러므로 점 세 개의 이동 관계를 알고 있다면 여섯 개의 원소로 정의되는 어파인 변환 행렬을 구할 수 있다.
  • 아래 그림은 점 세 개의 이동 관계에 의해 결정되는 어파인 변환을 보여준다.
    • 어파인 변환에 의해 직사각형 영상은 평행사변형 형태로 변환될 수 있기 때문에 입력 영상의 좌측 하단 모서리 점이 이동하는 위치는 자동으로 결정된다.
    • 그러므로 어파인 변환은 점 세 개의 이동 관계만으로 정의할 수 있다.

  • OpenCV는 어파인 변환 행렬을 구하는 함수와 어파인 변환 행렬을 이용하여 실제 영상을 어파인 변환하는 함수를 모두 제공한다.
    • 어파인 변환 행렬을 구하는 함수 이름은 getAffineTransform()으로, 입력 영상에서 세 점의 좌표와 이점들이 이동한 결과 영상의 좌표 세 개를 입력 받아 2×3 어파인 변환 행렬을 계산한다.
    • 2×3 어파인 변환 행렬을 가지고 있을 때 영상을 어파인 변환한 결과 영상을 생성하려면 warpAffine() 함수를 사용한다. wrapAffine() 함수는 src 영상을 어파인 변환하여 dst 영상을 생성한다.
Mat src = imread("tekapo.bmp");

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Point2f srcPts[3], dstPts[3];
srcPts[0] = Point2f(0, 0);
srcPts[1] = Point2f(src.cols - 1, 0);
srcPts[2] = Point2f(src.cols - 1, src.rows - 1);
dstPts[0] = Point2f(50, 50);
dstPts[1] = Point2f(src.cols - 100, 100);
dstPts[2] = Point2f(src.cols - 50, src.rows - 50);

Mat M = getAffineTransform(srcPts, dstPts);

Mat dst;
warpAffine(src, dst, M, Size());

imshow("src", src);
imshow("dst", dst);

이동 변환

  • 영상의 이동 변환(translation transformation)은 영상을 가로 또는 세로 방향으로 일정 크기만큼 이동 시키는 연산을 의미하며, 시프트(shift) 연산이라고도 한다.

  • 입력 영상의 모든 좌표를 x 방향으로 a 만큼, y 방향으로 b 만큼 이동하는 변환을 수식으로 나타내면 다음과 같다.

\begin{cases} x' = x + a \\ y' = y + b \end{cases}

  • 이 수식에서 (x, y) 는 입력 영상의 픽셀 좌표이고, (x', y') 는 결과 영상의 픽셀 좌표이다. 앞 수식을 행렬을 이용하면 다음과 같이 하나의 식으로 표현할 수 있다.

\left[ \begin{array}{rr} x' \\ y'  \end{array} \right] = \left[ \begin{array}{rr} 1 & 0 \\ 0 & 1 \end{array} \right] \left[ \begin{array}{rr} x \\ y \end{array} \right] + \left[ \begin{array}{rr} a \\ b \end{array} \right]

  • 앞 수식에서 입력 영상의 좌표를 나타내는 행렬 \left[ \begin{array}{rr} x \\ y \end{array} \right] 앞의 2×2 행렬과 그 뒤에 더해지는 2×1 행렬을 합쳐서 하나의 2×3 행렬을 구성하면 이동 변환을 나타내는 어파인 변환 행렬을 만들 수 있다.
    • 즉 영상을 x 방향으로 a 만큼, y 방향으로 b 만큼 이동하는 어파인 변환 행렬 M은 다음과 같다.

M = \left[ \begin{array}{rrr} 1 & 0 & a \\ 0 & 1 & b \end{array} \right]

  • 그러므로 OpenCV에서 영상을 이동 변환하려면 앞과 같은 형태의 2×3 실수 행렬 M을 만들고 이를 wrapAffine() 함수 인자로 전달해야 한다.
Mat src = imread("tekapo.bmp");

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Mat M = Mat_<double>({2, 3}, {1, 0, 150, 0, 1, 100});

Mat dst;
warpAffine(src, dst, M, Size());

imshow("src", src);
imshow("dst", dst);

전단 변환

  • 전단 변환(shear transformation)은 직사각형 형태의 영상을 한쪽 방향으로 밀어서 평행사변형 모양으로 변형되는 변환이며 층밀림 변환이라고도 한다.
    • 전단 변환은 가로 방향 또는 세로 방향으로 각각 정의할 수 있다.

  • y 좌표가 증가함에 따라 영상을 가로 방향으로 조금씩 밀어서 만드는 전단 변환 수식은 다음과 같다.

\begin{cases} x' = x + m_{x}y \\ y' = y \end{cases} 또는 \left[ \begin{array}{rr} x' \\ y'  \end{array} \right] = \left[ \begin{array}{rr} 1 & m_{x} \\ 0 & 1 \end{array} \right] \left[ \begin{array}{rr} x \\ y \end{array} \right] + \left[ \begin{array}{rr} 0 \\ 0 \end{array} \right]

  • x 좌표가 증가함에 따라 영상을 세로 방향으로 조금씩 밀어서 만드는 전단 변환 수식은 다음과 같다.

\begin{cases} x' = x \\ y' = m_{y}x + y \end{cases} 또는 \left[ \begin{array}{rr} x' \\ y'  \end{array} \right] = \left[ \begin{array}{rr} 1 & 0 \\ m_{y} & 1 \end{array} \right] \left[ \begin{array}{rr} x \\ y \end{array} \right] + \left[ \begin{array}{rr} 0 \\ 0 \end{array} \right]

  • 앞의 두 수식에서 m_{x} m_{y} 는 영상으로 각각 가로 방향과 세로 방향으로 밀림 정도는 나타내는 시룻이다. 결국 전단 변환을 나타내는 2×3 어파인 변환 행렬 M은 다음과 같이 나타낼 수 있다.

M = \left[ \begin{array}{rrr} 1 & m_{x} & 0 \\ 0 & 1 & 0 \end{array} \right] 또는 M = \left[ \begin{array}{rrr} 1 & 0 & 0 \\ m_{y} & 1 & 0 \end{array} \right]

Mat src = imread("tekapo.bmp");

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

double mx = 0.3;
Mat M = Mat_<double>({2, 3}, {1, mx, 0, 0, 1, 0});

Mat dst;
warpAffine(src, dst, M, Size(cvRound(src.cols + src.rows * mx), src.rows));

imshow("src", src);
imshow("dst", dst);

크기 변환

  • 영상의 크기 변환(scale transformation)은 영상의 전체적인 크기를 확대 또는 축소하는 변환이다.

  • 원본 영상의 가로 픽셀 크기가 w 이고 결과 영상의 가로 크기가 w' 이기 때문에 가로 방향으로의 크기 변환 비율 s_{x} s_{x} = w' / w 수식으로 계산할 수 있다. 마찬가지로 y 방향으로의 크기 변환 비율 s_{y} s_{y} = h' / h 수식으로 계산된다.
    • 입력 영상 좌표 (x, y) 로부터 크기 변환 결과 영상의 좌표 (x', y') 를 계산하는 수식은 다음과 같다.

\begin{cases} x' = s_{x}x \\ y' = s_{y}y \end{cases} 또는 \left[ \begin{array}{rr} x' \\ y'  \end{array} \right] = \left[ \begin{array}{rr} s_{x} & 0 \\ 0 & s_{y} \end{array} \right] \left[ \begin{array}{rr} x \\ y \end{array} \right] + \left[ \begin{array}{rr} 0 \\ 0 \end{array} \right]

  • 위 수식에서 s_{x} 또는 s_{y} 가 1보다 크면 영상이 확대되고 1보다 작으면 축소 된다.
  • 영상의 크기 변환을 나타내는 어파인 변환 행렬 M은 다음과 같다.

M = \left[ \begin{array}{rrr} s_{x} & 0 & 0 \\ 0 & s_{y} & 0 \end{array} \right]

  • 그러므로 앞과 같은 어파인 변환 행렬을 생성하고 wrapAffine() 함수를 이용하면 영상의 크기 변환을 수행할 수 있다.
  • 그러나 영상의 크기를 변경하는 작업은 실제 영상 처리 시스템에서 매우 빈번하게 사용되기 때문에 OpenCV는 보다 간단하게 크기를 변경할 수 있는 resize() 함수를 제공한다.
  • resize() 함수는 src 입력 영상을 dsize 크기로 확대 또는 축소한 dst 영상을 생성한다.
    • 영상의 크기는 dsize 인자를 통해 명시적으로 지정할 수도 있고, 또는 가로 방향 및 세로 방향으로의 크기 변환 비율은 fx와 fy 값을 통해 결정되도록 할 수도 있다.
    • 만약 결과 영상의 크기를 픽셀 단위로 지정하여 크기 변환을 수행하려면 dsize에 0이 아닌 값을 지정하고, fx와 fy는 0으로 설정한다.
    • 만약 입력 영상의 크기를 기준으로 크기 변환 비율을 지정하여 영상을 확대 또는 축소 하려면 dsize 인자에는 Size()를 지정하고 fx와 fy에는 0이 아닌 양의 실수를 지정한다.
    • 이 경우 결과 영상의 크기는 다음과 같이 설정된다.

\begin{cases} dst.rows = round(src.rows \times fx) \\ dst.cols = round(src.cols \times fy) \end{cases}

  • resize() 함수의 여섯 번쨰 인자 interpolation에는 보간법 알고리즘을 나타내는 InterpolationFlags 열거형 상수를 지정한다. 
    • 보간법은 결과 영상의 픽셀 값을 결정하기 위해 입력 영상 주변 픽셀 값을 이용하는 방식을 의미한다.
    • INTER_NEAREST 방법은 가장 빠르게 동장하지만 결과 영상 화질이 좋지 않다.
    • INTER_LINEAR 방법읍 연산 속도가 빠르고 화질도 충분히 좋은 편이라서 널리 사용되고 있고, resize() 함수에서 기본값으로 지정되어 있다.
    • INTER_LINEAR 보다 더 좋은 화질을 원한다면 INTER_CUBIC 또는 INTER_LANCZOS4 상수를 사용하는 것이 좋다.
    • 영사을 축소하는 경우 INTER_AREA 방법을 사용하면 무아레(moire) 현상이 적게 발생하며 화질 면에서 유리하다.
InterpolationFlags 열거형 상수 설명
INTER_NEAREST 최근방 이웃 보간법
INTER_LINEAR 양선형 보간법
INTER_CUBIC 3차 보간법
INTER_AREA 픽셀 영역 리샘플링
INTER_LANCZOS4 8×8 이웃 픽셀을 사용하는 란초스(Lanczos 보간법)
Mat src = imread("tekapo.bmp");

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Mat dst1, dst2, dst3, dst4;
resize(src, dst1, Size(), 4, 4, INTER_NEAREST);
resize(src, dst2, Size(1920, 1280);
resize(src, dst3, Size(1920, 1280), 0, 0, INTER_CUBIC);
resize(src, dst4, Size(1920, 1280), 0, 0, INTER_LANCZOS4);

imshow("src", src);
imshow("dst1", dst1(Rect(400, 500, 400, 400)));
imshow("dst2", dst2(Rect(400, 500, 400, 400)));
imshow("dst3", dst3(Rect(400, 500, 400, 400)));
imshow("dst4", dst4(Rect(400, 500, 400, 400)));

회전 변환

  • 영상의 회전 변환(rotation transformation)은 특정 좌표를 기준으로 영상을 원하는 각도만큼 회전하는 변환이다.

  • 영상의 회전 변환에 의해 입력 영상의 점 (x, y) 가 이동하는 점의 좌표 (x', y') 는 다음과 같이 삼각함수를 이용하여 구할 수 있다.

\begin{cases} x' = cos \theta \cdot x + sin \theta \cdot y \\ y' = -sin \theta \cdot x + cos \theta \cdot y \end{cases} 또는 \left[ \begin{array}{rr} x' \\ y'  \end{array} \right] = \left[ \begin{array}{rr} cos \theta & sin \theta \\ - sin \theta & cos \theta \end{array} \right] \left[ \begin{array}{rr} x \\ y \end{array} \right] + \left[ \begin{array}{rr} 0 \\ 0 \end{array} \right]

  • 영상을 반시계 방향으로 \theta 만큼 회전하는 어파인 변환 행렬 M은 다음과 같이 정의된다.

M = \left[ \begin{array}{rrr} cos \theta & sin \theta & 0 \\ - sin \theta & cos \theta & 0 \end{array} \right]

  • 그러므로 cos() 함수와 sin() 함수를 이용하여 앞과 같은 행렬을 생성하고 warpAffine() 함수를 사용하면 영상을 회전시킬 수 있다. 
  • 다만 영상을 회전하는 경우가 많기 떄문에 OpenCV에서는 영상의 회전을 위한 어파인 변환 행렬을 생성하는 getRotationMatrix2D() 함수를 제공한다.
    • 이 함수를 이용하면 영상을 원점이 아닌 특정 좌표를 기준으로 회전시키거나 필요한 경우 크기 변환까지 함께 수행하는 어파인 변환 행렬을 쉽게 만들 수 있다.
  • getRotationMatrix2D() 함수는 center 점을 기준으로 반시계 방향으로 angle 각도만큼 회전한 후, scale 크기만큼 확대 또는 축소하는 2×3 어파인 변환 행렬을 반환한다.
    • 만약 영상을 시계 방향으로 회전하는 어파인 변환 행렬을 구하고 싶다면 angle 인자에 음수를 지정한다.
    • getRotationMatrix2D() 함수가 반환하는 어파인 변환 행렬은 다음과 같이 계산된다.

\left[ \begin{array}{rrr} \alpha & \beta & (1-\alpha) \cdot center.x - \beta \cdot center.y \\ - \beta & \alpha & \beta \cdot center.x + (1 - \alpha) \cdot center.y \end{array} \right]

  • 이 수식에서 \alpha = scale \cdot cos(angle) 이고, \beta = scale \cdot sin(angle) 를 의미한다.
Mat src = imread("tekapo.bmp");

if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

Point2f cp(src.cols / 2.f, src.rows / 2.f);
Mat M = getRotationMatrix2D(cp, 20, 1);

Mat dst;
warpAffine(src, dst, M, Size());

imshow("src", src);
imshow("dst", dst);

대칭 변환

  • 대칭 변환은 입력 영상과 같은 크기의 결과 영상을 생성하며, 입력 영상의 픽셀과 결과 영상의 픽셀이 일대일로 대응되므로 보간법이 필요하지 않다.
  • 영상의 좌우 대칭 변환에 의한 좌표 변환 수식은 다음과 같다.

\begin{cases} x' = w - 1 - x \\ y' = y \end{cases}

  • 이 수식에서 (x, y) 는 입력 영상의 픽셀 좌표이고, (x', y') 는 결과 영상의 픽셀 좌표이며, w 는 입력 영상의 가로 크기이다. 앞 수식을 행렬 형태로 바꿔 쓰면 다음과 같다.

\left[ \begin{array}{rr} x' \\ y'  \end{array} \right] = \left[ \begin{array}{rr} -1 & 0 \\ 0 & -1 \end{array} \right] \left[ \begin{array}{rr} x \\ y \end{array} \right] + \left[ \begin{array}{rr} w-1 \\ 0 \end{array} \right]

  • 앞의 수식을 정리해서 보면 결국 좌우 대칭은 영상을 x 축 방향으로 -1배 크기 변환한 후 x 축 방향으로 w-1 만큼 이동 변환한 것과 같다. 그러므로 좌우 대칭 변환도 어파인 변환의 일종이다.
  • 영상의 상하 대칭 변환도 비슷한 방식으로 생각할 수 있으며 수식으로 정리하면 다음과 같다.

\begin{cases} x' = x \\ y' = h - 1 - y \end{cases} 또는 \left[ \begin{array}{rr} x' \\ y'  \end{array} \right] = \left[ \begin{array}{rr} 1 & 0 \\ 0 & -1 \end{array} \right] \left[ \begin{array}{rr} x \\ y \end{array} \right] + \left[ \begin{array}{rr} 0 \\ h - 1 \end{array} \right]

  • OpenCV는 영상의 대칭 변환을 수행하는 flip() 함수를 제공한다. flip() 함수는 영상을 가로 방향, 세로 방향, 또는 가로와 세로 양 방향에 대해 대칭 변환한 영상을 생성한다.
    • flip() 함수의 대칭 방법은 flipCode의 인자 부호에 따라 결정된다. 일반적으로 영상을 좌우로 대칭 변환하려면 flipCode에 1을 지정하고, 상하 대칭 변환을 하려면 0을 지정한다. 상하 대칭과 좌우 대칭을 모두 수행하려면 -1을 지정한다.
    • 참고로 상하 대칭과 좌우 대칭을 모두 수행한 결과는 입력 영상을 180도 회전한 것과 같다.
  • (예제는 flip() 함수를 사용하는 것이라 생략)

투시 변환

  • 영상의 기하학적 변환 중에는 어파인 변환보다 자유도가 높은 투시 변환(perspective transform)이 있다. 투시 변환은 직사각형 형태의 영상을 임의의 볼록 사각형 형태로 변경할 수 있는 변환이다.
    • 투시 변환에 의해 원본 영상에 있던 직선은 결과 영상에서 그대로 직선성이 유도되지만, 두 직선의 평행 관계는 깨어질 수 있다.
  • 아래 그림은 점 네 개의 이동 관계에 의해 결정되는 투시 변환을 나타낸다.
    • 투시 변환은 직선의 평행 관계가 유지되지 않기 때문에 결과 영상의 형태가 임의의 사각형으로 나타나게 된다. 
    • 점 하나의 이동 관계로부터 x 좌표에 대한 방정식 하나와 y 좌표에 대한 방정식 하나를 얻을 수 있으므로, 점 네 개의 이동 관계로부터 여덟 개의 방정식을 얻을 수 있다.
    • 이 여덟 개의 방정식으로부터 투시 변환을 표현하는 파라미터 정보를 계산할 수 있다.

  • 투시변환은 보통 3×3 크기의 실수 행렬로 표현한다.
    • 투시 변환은 여덟 개의 파라미터로 표현할 수 있지만, 좌표 계산의 편의상 아홉 개의 원소를 갖는 3×3 행렬을 사용한다.
    • 투시 변환을 표현하는 행렬을 M_{p} 라고 하면, 입력 영상의 픽셀 좌표 (x, y) 가 행렬 M_{p} 에 의해 이동하는 결과 영상 픽셀 좌표 (x', y') 는 다음과 같이 계산된다.

\left( \begin{array}{rrr} wx' \\ wy' \\w \end{array} \right) = M_{p} \left( \begin{array}{rrr} x \\ y \\ 1 \end{array} \right) = \left( \begin{array}{rrr} p_{11} & p_{12} & p_{13} \\ p_{21} & p_{22} & p_{23} \\ p_{31} & p_{32} & p_{33} \end{array} \right) \left( \begin{array}{rrr} x \\ y \\ 1 \end{array} \right)

  • 앞의 행렬 수식에서 입력 좌표와 출력 좌표를 (x, y, 1), (wx', wy', w) 형태로 표현한 것을 동차 좌표계(homogeneous coordinates)라고 하며, 좌표 계산의 편의를 위해 사용하는 방식이다.
    • 여기서 w 는 결과 영상의 좌표를 표현할 때 사용되는 비례 상수이며 w = p_{31}x + p_{32}y + p_{33} 형태로 계산된다. 
    • 그러므로 x y 는 다음과 같이 구할 수 있다.

x' = { p_{11}x + p_{12}y + p_{13} \over p_{31}x + p_{32}y + p_{33} },  y' = { p_{21}x + p_{22}y + p_{23} \over p_{31}x + p_{32}y + p_{33} }

  • OpenCV는 투시 변환 행렬을 구하는 함수와 투시 변환 행렬을 이용하여 실제 영상을 투시 변환하는 함수를 모두 제공한다.
    • 투시 변환 행렬을 구하는 함수 이름은 getPerspectiveTransform()으로 입력 영상에서 네 점의 좌표와 이 점들이 이동한 결과 영상의 좌표 네 개를 입력 받아 3×3 투시 변환 행렬을 계산한다.
    • 3×3 투시 변환 행렬을 가지고 있을 때, 영상을 투시 변환한 결과 영상을 생성하려면 warpPerspective() 함수를 사용한다. 이 함수는 투시 변환 행렬 M을 이용하여 src 영상으로부터 dst 영사을 생성한다. 이때 전달되는 투시 변환 행렬 M은 CV_32FC1 또는 CV_64FC1 타입이어야 하고 크기는 3×3 이어야 한다.
Mat src;
Point2f srcQuad[4], dstQuad[4];

void on_mouse(int event, int x, int y, int flags,void* userdata);

int main()
{
if (src.empty())
{
cerr << "Image load failed!" << endl;
return;
}

namedWindow("src");
setMouseCallback("src", on_mouse);

imshow("src");
waitKey(0);

return 0;
}

void on_mouse(int event, int x, int y, int flags, void*)
{
static int cnt = 0;

if (event == EVENT_LBUTTONDOWN)
{
if (cnt < 4)
{
srcQuad[cnt++] = Point2f(x, y);

circle(src, Point(x, y), 5, Scalar(0, 0, 255), -1);
imshow("src", src);

if (cnt == 4)
{
int w = 200, h = 300;

dstQuad[0] = Point2f(0, 0);
dstQuad[1] = Point2f(0, 0);
dstQuad[2] = Point2f(0, 0);
dstQuad[3] = Point2f(0, 0);

Mat pers = getPerspectiveTransform(srcQuad, dstQuad);

Mat dst;
warpPerspective(src, dst, pers, Size(w, h));

imshow("dst", dst);
}
}
}
}
  • 만일 3×3 투시 변환 행렬을 가지고 있을 때, 일부 점들이 투시 변환에 의해 어느 위치로 이동할 것인지 알고 싶다면 perspectiveTransform() 함수를 사용할 수 있다.

OpenCV 4로 배우는 컴퓨터 비전과 머신 러닝/ 필터링

영상의 필터링

필터링 연산 방법

  • 영상 처리에서 필터링(filtering)이란 영상에서 원하는 정보만 통과시키고 원치 않는 정보는 걸러 내는 작업.
    • 잡음(noise)를 걸러 내어 영상을 깔금하게 만드는 필터가 있고, 부드러운 느낌의 성분을 제거함으로써 날카로운 느낌이 나도록 할 수도 있다.
  • 영상의 필터링은 보통 마스크(mask)라고 부르는 작은 크기의 행렬을 이용한다.
    • 마스크는 필터링의 성격을 정의하는 행렬이며 커널(kernel), 윈도우(window)라고도 부르며 경우에 따라 마스크 자체를 필터라고 하기도 한다.
  • 마스크는 다양한 크기와 모양으로 정의할 수 있으며, 마스크 행렬의 원소는 보통 실수로 구성된다.
    • 여러 모양의 필터 마스크 중에 3 x 3 정방형 행렬이 다양한 필터링 연산에서 가장 널리 사용된다.
    • 위 그림에서 필터 마스크의 가운데 위치한 진한 색을 고정점(anchor point)라고 하는데, 고정점은 현재 필터링 작업을 수행하고 있는 기준 픽셀 위치를 나타낸다. 대부분의 경우 마스크 행렬의 정중앙을 고정점으로 사용한다.

  • 필터링 연산의 결과는 마스크 행렬의 모양과 원소 값에 의해 결정된다. 
    • 즉, 마스크 행렬을 어떻게 정의하는가에 따라 영상을 부드럽게도, 날카롭게도 할 수 있다.
    • 또는 영상에서 잡음을 제거하거나 에지(edge) 성분만 나타나도록 할 수도 있다.
  • 아래 그림은 3 x 3 정방형 마스크를 이용한 필터링 수행 방법이다.
    • 아래 그림에서 m은 마스크 행렬을 나타내고, f와 g는 각각 입력 영상과 출력 영상을 의미한다.
    • 이 그림에서 마스크 행렬 크기가 3 x 3이므로 고정점의 좌표는 중심 좌표인 (1, 1)로 설정했다.
  • 마스크를 이용한 필터링은 입력 영상의 모든 픽셀 위로 마스크 행렬을 이동시키면서 마스크 연산을 수행하는 방식으로 이루어진다.

  • 마스크 연산이란 마스크 행렬의 모든 원소에 대하여 마스크 행렬 원소 값과 같은 위치에 있는 입력 영상 픽셀 값을 서로 곱한 후, 그 결과를 모두 더하는 연산이다.
    • 그리고 마스크 연산의 결과를 출력 영상에서 고정점 위치에 대응되는 픽셀 값으로 설정한다.
    • 그러므로 마스크 행렬 m의 중심이 입력 영상의 (x, y) 좌표 위에 위치했을 때 필터링 결과 영상의 픽셀 값 g(x, y)는 다음과 같이 계산된다.

g(x, y) = m(0, 0) f(x-1, y-1) \\ + m(1, 0) f(x, y-1) \\ + m(2, 0) f(x+1, y-1) \\ + m(0, 1) f(x-1, y) \\ + m(1, 1) f(x, y) \\ + m(2, 1) f(x+1, y) \\ + m(0, 2) f(x-1, y+1) \\ + m(1, 2) f(x, y+1) \\ + m(2, 2) f(x+1, y+1)

  • (x, y) 좌표에서 마스크 연산을 통해 결과 영상의 픽셀 값 g(x, y)를 구했으면, 다음에는 마스크를 한 픽셀 옆으로 이동하여 (x+1, y) 좌표에 다시 마스크 연산을 수행하고 그 결과를 g(x+1, y)에 저장한다. 이 과정을 영상 전체 픽셀에 대해 수행하면 필터링이 완료 된다.
  • 그런데 영상의 가장 바깥쪽 픽셀에서는 (x, y) 자리에 영상에 존재하지 않는 좌표가 들어오게 된다.
    • 이 식은 계산할 수 없기 때문에 영상의 가장자리 픽셀에 대해 필터링을 수행할 때는 특별한 처리를 해야 한다.
  • OpenCV에서는 영상의 필터링을 수행할 때 영상의 가장자리 픽셀을 확장하여 영상 바깥쪽에 가상의 픽셀을 만든다.
    • 이때 바깥쪽 가상의 픽셀 값을 어떻게 설정하는가에 따라 필터링 연산 결과가 달라진다.
  • 아래 이미지는 5 x 5 크기의 필터 마스크를 적용하는 예시로서, 노란색으로 표시된 실제 존재하는 영상에 대해 가상의 픽셀을 구성하여 분홍색 픽셀을 만든 이미지이다.
    • 분홍색 픽셀의 영문자는 실제 픽셀의 영문자와 동일한 위치의 픽셀값을 나타낸다.
    • 아래 이미지는 실제 영상의 픽셀 값이 대칭 형태로 나타나도록 설정되어 있는 모습이다.

  • 대부분의 OpenCV 필터링 함수는 위와 같은 방식으로 가장자리 픽셀을 확장하지만 다른 방식으로 가상의 픽셀 값을 설정할 수도 있다.
    • 이에 대한 내용은 아래 표 참조

  • OpenCV에서 필터 마스크를 사용하는 일반적인 필터링은 filter2D() 함수를 이용하여 수행한다.
    • filter2D() 함수는 src 영상에 kernel 필터를 이용하여 필터링을 수행하고, 그 결과를 dst에 저장한다.
    • 만약 src 인자와 dst 인자에 같은 변수를 지정하면 필터링 결과를 입력 영상에 덮어쓰게 된다.
    • filter2D() 함수가 수행하는 연산을 수식으로 표현하면 다음과 같다.

dst(x, y) = \sum_{j} \sum_{i} kernel(i, j) \cdot src(x+i-anchor.x, y+j-anchor.y) + delta

  • filter2D() 함수 인자 중에서 ddepth는 결과 영상의 깊이를 지정하는 용도로 사용하며, 입력 영상 깊이에 따라 지정할 수 있는 ddepth 값은 아래 표와 같다.
    • 만약 ddepth에 -1을 지정하면 출력 영상의 깊이는 입력 영상과 같게 설정된다.
입력 영상의 깊이(src.depth()) 지정 가능한 ddepth값
CV_8U -1/ CV_16S/ CV_32F/ CV_64F
CV_16U/CV_16S -1/ CV_32F/ CV_64F
CV_32F -1/ CV_32F/ CV_64F
CV_64F -1/ CV_64F

 

  • Note)
    • 3 x 3 필터 마스크를 이용하여 입력 영상 src와 필터링을 수행하는 수식을 쓰면 다음과 같다.

dst(x, y) = \sum_{j=0}^{2} \sum_{i=0}^{2} m(i, j) \cdot src(x+i-1, y+j-1)

  • 앞 수식은 입력 영상 (x, y) 좌표에 마스크 행렬을 올려놓고, 같은 위치에 있는 마스크 행렬 원소와 입력 영상 픽셀 값을 모두 곱한 후 더하는 연산이다.
    • 이처럼 두 개의 신호가 있을 때 같은 위치에 있는 신호 값을 모두 곱한 후 다시 더하는 연산을 신호 처리 분야에서 코릴레이션(correlation) 또는 상관이라고 한다.
    • 신호 처리에서 코릴레이션은 두 신호의 유사성을 판단하는 기준으로 사용되기도 한다.
  • 두 개의 연속 신호 f와 g가 있을 때 두 신호의 코릴레이션을 구하는 수식은 다음과 간다.

(f \otimes g)(t) \int_{a}^{b} f^{*}(\tau)g(t+\tau)d\tau

  • 그런데 영상 필터링을 신호 처리의 컨볼루션(convolution) 연산이라고 말하는 경우가 많다. 컨볼루션은 회선 또는 합성곱이라고도 하며, 두 신호의 컨볼루션은 다음 수식으로 정의된다.

(f * g)(t) \int_{a}^{b} f(\tau)g(t-\tau)d\tau

  • 컨볼루션은 두 입력 신호 중 하나를 원점 기준 대칭 변환한 후 코릴레이션을 구하는 것과 같다.
    • 그러므로 2차원 마스크 행렬과 입력 영상의 컨볼루션 연산을 정확하게 수행하려면 마스크 행렬을 상하 및 좌우 대칭으로 변환한 후 필터링 연산을 수행해야 한다.
    • 그러나 필터 마스크가 상하 및 좌우 대칭으로 구성되어 있는 경우에는 코릴레이션과 컨볼루션의 결과는 서로 같다.
    • 영상 처리에서 널리 사용되고 있는 많은 필터 마스크가 상하 및 좌우 대칭으로 구성되어 있기 때문에 관용적으로 필터링 연산을 컨볼루션 연산이라고 부르고 있다.

엠보싱 필터링

  • 엠보싱이랑 직물이나 종이, 금속판 등에 올록볼록한 형태로 만든 객체의 윤곽 또는 무늬를 뜻하며, 엠보싱 필터는 입력 영상을 엠보싱 느낌이 나도록 변환하는 필터이다.
    • 보통 입력 영상에서 픽셀 값 변화가 적은 평탄한 영역은 회색으로 설정하고, 객체의 경계 부분은 좀 더 밝거나 어둡게 설정하면 엠보싱 느낌이 난다.
  • 아래 이미지는 간단한 형태의 엠보싱 필터 마스크의 예이다.
    • 필터 마스크는 대각선 방향으로 +1 또는 -1의 값이 지정되어 있는 3 x 3 행렬이다.
    • 이 필터 마스크를 사용하여 필터링을 수행하면 대각선 방향으로 픽셀 값이 급격하게 변하는 부분에서 결과 영상 픽셀 값이 0보다 훨씬 크거나 0보다 훨씬 작은 값을 가지게 된다.
    • 입력 영상에서 픽셀 값이 크게 바뀌지 않는 평탄한 영역에서는 결과 영상의 픽셀 값이 0에 가까운 값을 갖게 된다.
    • 이렇게 구한 영상을 그대로 화면에 나타내면 음수 값이 포화 연산에 의해 0이 되어 버리기 때문에 결과 영상에 18을 더하는 것이 보기에 좋다.

블러링

  • 블러링(blurring)은 초점이 맞지 않는 사진처럼 영상을 부드럽게 만드는 필터링 기법으로 스무딩(smoothing)이라고도 한다.
    • 영상에서 인접한 픽셀 간의 픽셀 값 변화가 크지 않은 경우 부드러운 느낌을 받을 수 있다.
    • 블러링은 거친 느낌의 입력 영상을 부드럽게 만드는 용도로 사용되기도 하고, 입력 영상에 존재하는 잡음의 영향을 제거하는 전처리 과정으로도 사용된다.

평균값 필터

  • 평균값 필터(mean filter)는 입력 영상에서 특정 픽셀과 주변 픽셀들의 산술 평균을 결과 영상 픽셀 값으로 설정하는 필터이다.
    • 평균값 필터에 의해 생성되는 결과 영상은 픽셀 값의 급격한 변화가 줄어들어 날카로운 에지가 무뎌지고 잡음의 영향이 크게 사라지는 효과가 있다.
    • 그러나 평균값 필터를 과도하게 사용하면 사물의 경계가 흐릿해지고 사물의 구분이 어려워질 수 있다.
  • 아래 이미지는 다양한 크기의 평균값 필터 마스크를 나타낸 것이다.
    • 각각의 행렬은 모두 원소가 1로 설저외더 있고 행렬의 전체 원소 개수로 각 행렬 원소 값을 나누는 형태로 표현되어 있다.
    • 결국 3 x 3 평균값 필터 마스크는 모든 원소가 1/9로 설정된 행렬이고, 5 x 5 평균값 필터는 모든 원소가 1/25로 구성된 행렬이다.
    • 평균값 필터는 마스크의 크기가 커질 수록 부드러운 느낌의 영상을 생성하지만, 연산량이 크게 증가할 수 있다.

  • OpenCV에서는 blur() 함수를 이용하여 평균값 필터링을 수행할 수 있다.
    • blur() 함수는 src 영상에 ksize 크기의 평균값 필터 마스크를 사용하여 dst 출력 영상을 생성한다.
    • blur() 함수에서 사용하는 커널은 다음과 같은 형태를 갖고 있다.

kernel = { 1 \over ksize.width \times ksize.height } \left[ \begin{array}{rrrr} 1 & 1 & ... & 1 \\ 1 & 1 & ... & 1 \\ ... & ... & ... & ... \\ 1 & 1 & ... & 1 \end{array} \right]

  • Note)
    • 일반적으로 필터 마스크 행렬은 모든 원소 합이 1 또는 0이 되도록 설계한다. 필터 마스크 행렬의 원소 합이 1이면 필터링 결과와 영상의 평균 밝기가 입력 영상 평균 밝기와 같에 유지되기 때문이다.
    • 만일 필터 마스크 행렬 원소의 합이 1보다 작으면 입력 영상보다 어두운 영상이 되고, 1보다 크면 밝은 결과 영상이 만들어진다.

가우시안 필터

  • 가우시안 필터(Gaussian filter)는 가우시안 분포(Gaussian distribution) 함수를 근사하여 생성한 필터 마스크를 사용하는 필터링 기법이다.
  • 가우시안 분포는 평균을 중심으로 좌우 대칭의 종 모양(bell shape)을 갖는 확률 분포를 말하며 정규 분포(normal distribution)라고도 한다.
  • 가우시안 분포는 평균과 표준 편차에 따라 분포 모양이 결정된다. 다만 영상의 가우시안 필터에서는 주로 평균이 0인 가우시안 분포 함수를 사용한다.
    • 평균이 0이고 표준 편차가 \sigma 인 1차원 가우시안 분포를 함수식으로 나타내면 다음과 같다.

G_{\sigma}(x) = {1 \over \sqrt{2 \pi \sigma}} e^{- {x^{2} \over 2 \sigma^{2}}}

  • 평균이 0이고 표준 편차 \sigma 가 각각 0.5, 1.0, 2.0인 가우시안 분포 그래프를 그리면 아래와 같다.
    • 세 개의 그래프가 모두 평균이 0이므로 x=0에서 최댓값을 가지며, x가 0에서 멀어질수록 함수 값이 감수한다.
    • \sigma 가 작으면 그래프가 뾰족한 형태가 되고, \sigma 가 크면 그래프가 넓게 퍼지면서 완만한 형태를 따른다.
    • 가우시안 분포 함수값은 특정 x가 발생할 수 있는 확률의 개념을 가지며, 그래프 면적을 합하면 1이 된다.

  • 가우시안 분포를 따르는 2차원 필터 마스크 행렬을 생성하려면 2차원 가우시안 분포 함수를 근사해야 한다.
    • 2차원 가우시안 분포 함수는 x와 y 두 개의 변수를 사용하고, 분포의 모양을 결정하는 평균과 표준 편차도 x축과 y축 방향에 따라 따로 설정한다.
    • 평균이 (0, 0)이고 x축과 y축 방향의 표준 편차가 각각 \sigma_{x}, \sigma_{y} 인 2차원 가우시안 분포 함수는 다음과 같이 정의 된다.

G_{\sigma_{x}, \sigma_{y}}(x, y) = {1 \over \sqrt{2 \pi \sigma_{x} \sigma_{y}}} e^{- ({x^{2} \over 2 \sigma_{x}^{2}} + {y^{2} \over 2 \sigma_{y}^{2}})}

  • 평균은 (0, 0)이고 \sigma_{x} = \sigma_{y} = 1.0 인 2차원 가우시안 분포 함수 그래프를 그리면 아래와 같다.
    • 평균이 (0, 0)이므로 (0, 0)에서 최댓값을 갖고, 평균에서 멀어질수록 함수가 감소한다.
    • 함수 그래프의 부피를 구하면 1이 된다.

  • 가우시안 필터는 이러한 2차원 가우시안 분포 함수로부터 구한 마스크 행렬을 사용한다. 가우시안 분포 함수는 연속 함수지만 이산형의 마스크를 만들기 위해 x와 y 값이 정수인 위치에서만 가우시안 분포 함수 값을 추출하며 마스크를 생성한다.
    • 평균이 0이고 표준 편차가 \sigma 인 가우시안 분포는 x가 -4 \sigma 부터 4 \sigma 사이인 구간에서 그 값의 대부분이 존재하기 때문에 가우시안 필터 마스크의 크기는 보통 (8 \sigma + 1) 로 결정한다.
    • 예컨대 위 그림과 같이 \sigma_{x} = \sigma_{y} = 1.0 인 가우시안 함수를 사용할 경우, x = \{ -4, -3, -2, -1, 0, 1, 2, 3, 4 \}, y = \{ -4, -3, -2, -1, 0, 1, 2, 3, 4 \} 인 경우에만 가우시안 분포 함수 값을 추출하여 필터 마스크를 생성한다.
    • 이러한 방식으로 추출한 9 x 9 가우시안 필터 마스크가 아래 그림과 같다.

  • 위 그림에 나타난 가우시안 필터 마스크 행렬은 중앙부에서 비교적 큰 값을 가지고, 주변부로 갈수록 원소 값이 0에 가까운 작은 값을 가진다.
    • 그러므로 이 필터 마스크를 이용하여 마스크 연산을 수행한다는 것은 필터링 대상 픽셀 근처에는 가중치를 크게 주고, 필터링 대상 필셀과 멀리 떨어져 있는 주변부에는 가중치를 조금만 주어서 가중 평균(weighted average)을 구하는 것과 같다.
    • 즉, 가우시안 필터 마스크가 가중 평균을 구하기 위한 가중치 행렬 역할을 하는 것이다.
  • 마스크 연산에 의한 영상 필터링은 마스크 크기가 커짐에 따라 연산량도 함께 증가한다.
    • 9 x 9 행렬의 경우 한 번의 마스크 연산 시 81번의 곱셈 연산이 필요하다. 또한 큰 표준 편차 값을 사용하면 마스크 크기도 함께 커지므로 연산 속도 측면에서 부담이 될 수 있다.
    • 다행히 2차원 가우시안 분포 함수는 1차원 가우시안 분포 함수의 곱으로 분리할 수 있으며, 이러한 특성을 이용하면 가우시안 필터 연산을 크게 줄일 수 있다.
    • 2차원 가우시안 분포 함수 수식은 아래와 같이 분리하여 작성할 수 있다.

G_{\sigma_{x}, \sigma_{y}}(x, y) = {1 \over \sqrt{2 \pi \sigma_{x} \sigma_{y}}} e^{- ({x^{2} \over 2 \sigma_{x}^{2}} + {y^{2} \over 2 \sigma_{y}^{2}})} \\ = {1 \over \sqrt{2 \pi \sigma_{x}}} e^{- {x^{2} \over 2 \sigma_{x}^{2}}} \times {1 \over \sqrt{2 \pi \sigma_{y}}} e^{- {y^{2} \over 2 \sigma_{y}^{2}}} \\ = G_{\sigma_{x}}(x) \cdot G_{\sigma_{y}}(y) 

  • 이처럼 2차원 필터 마스크 생성 함수를 x축 방향으로 함수와 y축 방향으로의 함수로 분리할 수 있을 경우, 입력 영상을 x축 방향으로의 함수와 y축 방향으로의 함수로 각각 1차원 마스크 연산을 수행함으로써 필터링 결과 영상을 얻을 수 있다.
  • 예컨대 \sigma_{x} = \sigma_{y} = 1.0 인 2차원 가우시안 마스크로 영상을 필터링하는 것은 \sigma = 1.0 인 1차원 가우시안 마스크를 가로 방향과 세로 방향으로 각각 생성하여 두 번 필터링 하는 것과 같다.
    • 실제로 \sigma = 1.0 인 1차원 가우시안 함수로부터 1 x 9 가우시안 마스크 행렬은 다음과 같다.

g = \left( \begin{array}{rrrrrrrrr} 0.0001 & 0.0044 & 0.0540 & 0.2420 & 0.3989 & 0.2420 & 0.0540 & 0.0044 & 0.0001 \end{array} \right)

  • 그러므로 행렬 g 를 이용하여 필터링을 한 번 수행하고, 그 결과를 다시 g 의 전치 행렬인 g^{T} 를 이용하여 필터링 하는 것은 2차원 가우시안 필터 마스크로 한 번 필터링 하는 것과 같은 결과를 얻을 수 있다.
    • 이 경우 픽셀 하나에 대해 필요한 곱셈 연산 횟수가 18번으로 감소하여 연산량이 크게 줄어든다.
  • OpenCV에서 가우시안 필터링을 수행하려면 GaussianBlur() 함수를 사용하면 된다.
    • GaussianBlur() 함수는 src 영상에 가우시안 필터링을 수행하고 그 결과를 dst 영상에 저장한다. x축과 y축 방향으로의 표준 편차 sigmaX와 sigmaY는 서로 다른 값을 지정해도 되지만, 특정한 이유가 없다면 sigmaX와 sigmaY는 같은 값을 사용한다.
    • GaussianBlur() 함수에서 sigmaY인자를 지정하지 않거나 0으로 설정하면 y축 방향에 대해서도 sigmaX와 같은 표준편차를 사용한다.
    • 또한 가우시안 필터의 크기를 지정하는 ksize 인자에도 특별한 이유가 없다면 Size()를 전달하여 적절한 필터 크기를 자동으로 결정하도록 하는 것이 좋다.
  • 가우시안 필터링은 x축 방향과 y축 방향으로 각각 1차원 가우시안 필터를 적용하여 수행한다고 설명했는데 실제로 GaussianBlur() 함수 내부에서 가우시안 필터링을 구현할 때에도 x축 방향과 y축 방향에 따라 1차원 가우시안 필터 마스크를 각각 생성하여 필터링을 수행한다.
    • 이때 1차원 가우시안 필터 마스크를 생성하기 위해 OpenCV에서 제공하는 getGaussianKernel() 함수를 사용한다. 이 함수는 사용자가 지정한 표준 편차를 따르는 1차원 가우시안 필터 마스크 행렬을 생성하여 반환한다.
    • getGaussianKernel() 함수는 표준 편차가 sigma인 1차원 가우시안 분포 함수로부터 ksize x 1 크기의 필터 마스크 행렬을 생성하여 반환한다.
    • ksize는 (8 x sigma + 1) 보다 같거나 크게 지정하는 것이 좋다. 
    • 이 행렬의 원소에 저장되는 값은 다음의 수식을 따른다.

G_{i} = \alpha \cdot e^{{(i - (ksize-1) \div 2)^{2} \over 2 \sigma^{2}}}

  • 앞 수식에서 i = 0, 1, ... , ksize - 1 의 범위를 가지며, \alpha \sum_{i} G_{i} = 1 이 되도록 만드는 상수이다.
  •  Note)
    • getGaussianKernel() 함수는 ksize 값이 7보다 같거나 작고 sigma 값이 0 또는 음수인 경우에는 미리 정해 놓은 배열 값을 이용하여 커널 행렬을 생성한다.

샤프닝

언샤프 마스크 필터

  • 샤프닝(sharpning)이란 영상을 날카로운 느낌이 나도록 변경하는 필터링 기법.
    • 날카로운 느낌의 영상이란 객체의 윤곽이 뚜렷하게 구분되는 영상을 의미한다.
    • 영상을 초점이 잘 맞은 사진처럼 보이게끔 변경하려면 영상 에지 근방에서 픽셀 값의 명암비가 커지도록 수정해야 한다.
  • 샤프닝 기법과 관련하여 흥미로운 사실은 샤프닝을 구현하기 위해 블러링된 영상을 사용한다는 점. 블러링이 적용되어 부드러워진 영상을 활용하여 반대로 날카로운 영상을 생성한다는 것이다.
    • 여기서 블러링이 적용된 영상, 즉 날카롭지 않은 영상을 언샤프(unsharp) 하다고 말하기도 한다.
    • 이처럼 언샤프한 영상을 이용하여 역으로 날카로운 영상을 생성하는 필터를 언샤프 마스크 필터(unsharp mask filter)라고 한다.
  • 언샤프 마스크 필터링의 과정을 아래 이미지를 통해 확인할 수 있다.
    • 아래 이미지의 가로축은 픽셀 좌표의 이동을 나타내며, 세로축은 픽셀 값을 나타낸다.
    • (a)는 영상의 에지 부근에서 픽셀 값이 증가하는 모양을 나타낸 것이다.
    • (b)에서 파란색 실선 그래프는 f(x, y) 에 블러링을 적용한 결과를 나타내며, 이를 \bar{f}(x, y) 로 표기. 블러링된 결과와 원본 픽셀값 변화를 비교해 볼 수 있도록 (b)에 f(x, y) 를 검은색 점선으로 표현하였다.
    • (c)는 입력 영상 f(x, y) 에서 블러링된 \bar{f}(x, y) 를 뺀 결과이며, 이를 g(x, y) 로 표기. 즉 g(x, y) = f(x, y) - \bar{f}(x, y) 가 된다.
    • g(x, y) 는 입력 함수 값이 증가하기 시작하는 부분에서 음수 값을 가지고, 입력 함수 값 증가가 멈추는 부근에서 양수 값을 가진다. 그러므로 입력 함수 f(x, y) g(x, y) 를 더하면 에지가 강조된 함수가 생성된다.
    • (d)에서 h(x, y) = f(x, y) + g(x, y) 가 샤프닝이 적용된 결과 영상이 된다.

  • g(x, y) 는 입력 영상에서 블러링된 영상을 뺀 결과이므로, g(x, y) 는 입력 영상에서 오직 날카로운 성분만 가지고 있는 함수라 할 수 있다.
    • 고로 입력 영상 f(x, y) g(x, y) 를 더함으로써 날카로운 성분이 강조된 최종 영상 h(x, y) 가 얻어지는 것으로 해석할 수 있다.
    • 그런데 f(x, y) g(x, y) 를 단순하게 더하는 것이 아니라 실수 가중치를 곱한 후 더하면 날카로운 정도를 조절할 수 있다.
    • 즉, 샤프닝이 적용된 결과 영상 h(x, y) 의 수식을 다음과 같이 정의할 수 있다.

h(x, y) = f(x, y) + \alpha \cdot g(x, y)

  • 위 식에서 \alpha 에 1보다 작은 값을 지정하면 덜 날카로운 영상을 만들 수 있다.
    • 위 식에서 g(x, y) 대신 f(x, y) - \bar{f}(x, y) 수식을 대입하여 식을 정리하면 다음과 같다.

h(x, y) = f(x, y) + \alpha (f(x, y) - \bar{f} (x, y)) \\ = (1 + \alpha) f(x, y) - \alpha \cdot \bar{f}(x, y)

  • OpenCV에서는 언샤프 마스크 필터 함수를 따로 제공하지 않으므로 위 수식을 이용하여 코드를 직접 작성해야 한다.

잡음 제거 필터링

영상과 잡음 모델

  • 신호 처리 관점에서 잡음(noise)란 원본 신호에 추가된 원치 않은 신호를 의미한다.
    • 영상에서 잡음은 주로 영상을 획득하는 과정에서 발생하며, 디지털 카메라에서 사진을 촬영하는 경우 광학적 신호를 전기적 신호로 변환하는 센서(sensor)에서 주로 잡음이 추가된다.
  • 디지털 카메라에서 카메라 렌즈가 바라보는 장면을 원본 신호 s(x, y) 라고 하고, 여기에 추가된 잡음을 n(x, y) 라고 한다면, 실제로 카메라에서 획득되는 영상 신호 f(x, y) 는 다음과 같이 표현한다.

f(x, y) = s(x, y) + n(x, y)

  • 잡음이 생성되는 방식을 잡음 모델(noise model)이라고 하며, 다양한 잡음 모델 중에서 가장 대표적인 잡음 모델은 가우시안 잡음 모델(Gaussian noise model)이다.
    • 가우시안 잡음 모델은 보통 평균이 0인 가우시안 분포를 따르는 잡음을 의미한다.
  • 아래 그림은 평균이 0이고 표쥰편차가 10인 1차원 가우시안 분포 그래프이다.
    • 평균이 0이고 표준 편차가 \sigma 인 가우시안 분포는 x 값이 -\sigma \leq x \leq \sigma 구간에서 전체 데이터의 67%가 존재하고, -2\sigma \leq x \leq 2\sigma 구간에는 95% -3\sigma \leq x \leq 3\sigma 구간에는 99.7%가 존재한다.
    • 그러므로 평균이 0이고 표준 편차가 10인 가우시안 분포를 따르는 잡음 모델은 67%의 확률로 -10에서 10 사이의 값이 잡음으로 추가된다. 잡음 값이 -20부터 20 사이일 확률은 95%이며, 그 밖의 값이 잡음으로 추가될 확률은 5%이다.
    • 그러므로 표준편차가 작은 가우시안 잡음 모델일수록 잡음에 의한 픽셀 값 변화가 적다고 생각할 수 있다.

양방향 필터

  • 대부분의 영상에는 가우시안 잡음이 포함되어 있으며, 많은 컴퓨터 비전 시스템이 가우시안 잡음을 제거하기 위해 가우시안 필터를 사용한다.
    • 입력 영상에서 픽셀 값이 크게 변하지 않는 평탄한 영역에 가우시안 필터가 적용될 경우, 주변 픽셀 값이 부드럽게 블러링되면서 자음의 영향도 크게 줄어든다.
  • 그러나 픽셀 값이 급격하게 변경되는 에지 근방에 동일한 가우시안 필터가 적요오디면 잡음뿐만 아니라 에지 성분까지 함께 감소하게 된다. 즉, 잡음이 줄어들면서 에지도 무뎌지기 때문에 객체의 윤곽이 흐릿하게 바뀌게 된다.
  • 이러한 단점을 보완하기 위해 많은 사람들이 에지 정보는 그대로 유지하면서 잡음만 제거하는 에지 보전 잡음 제거 필터(edge-preserving noise removal filter)에 대해 연구하였다.
    • 특히 1998년 토마시(C. Tomasi)가 제안한 양방향 필터(bilateral filter)는 에지 성분은 그대로 유지하면서 가우시안 잡음을 효과적으로 제거하는 알고리즘이다.
    • 양방향 필터 기능은 OpenCV 라이브러리 초기 버전부터 포함되어 있어서 많은 사람들이 사용하고 있다.
    • 양방향 필터는 다음 공식을 사용하여 필터링을 수행한다.

g_{p} = {1 \over W_{p}} \sum_{q \in S} G_{\sigma_{s}} (\|p - q\|) G_{\sigma_{r}}(|f_{p} - f_{q}|)f_{q}

  • 위 수식에서 f 는 입력 영상, g 는 출력 영상, 그리고 p q 는 픽셀의 좌표를 나타낸다.
    • f_{p} f_{q} 는 각각 p 점과 q 점에서의 입력 영상 픽셀 값이고, g_{p} p 점에서의 출력 영상 픽셀 값이다.
    • G_{\sigma_{s}} G_{\sigma_{r}} 는 각각 표준 편차가 \sigma_{s} \sigma_{r} 인 가우시안 분포 함수이다.
    • S 는 필터 크기를 나타내고, 
    • W_{p} 는 양방향 필터 마스크 합이 1이 되도록 만드는 정규화 상수이다.
  • 양방향 필터 수식은 복잡해 보이지만 가만히 살펴보면 두 개의 가우시안 함수 곱으로 구성된 필터이다.
    • G_{\sigma_{s}} (\|p - q\|) 함수는 두 점 사이의 거리에 대한 가우시안 함수로서 가우시안 필터와 완전히 동일한 의미로 동ㅈ가한다.
    • 반면 G_{\sigma_{r}}(|f_{p} - f_{q}|) 함수는 두 점의 픽셀 값 차이에 의한 가우시한 함수이다. G_{\sigma_{r}}(|f_{p} - f_{q}|) 함수는 두 점의 픽셀 밝기 값 차이가 적은 평탄한 영역에서는 큰 가중치를 갖게 만들고 에지를 사이에 두고 있는 두 픽셀 사이에 대해서는 |f_{p} - f_{q}| 값이 크게 나타나므로 상대적으로 G_{\sigma_{r}}(|f_{p} - f_{q}|) 는 거의 0에 가까운 값이 된다.
    • 이로 인해 에지 근방에서는 가우시안 블러링 효과가 거의 나타나지 않고 에지가 보존된다.
  • 양방향 필터 수식이 픽셀 값의 차이에 의존적이기 때문에 양방향 필터 마스크는 영상의 모든 픽셀에서 서로 다른 형태를 갖게 된다.
    • 즉 모든 픽셀 위치에서 주변 픽셀과의 밝기 차이에 의한 고유의 필터 마스크 행렬을 만들어서 마스크 연산을 수행해야 한다.
    • 이는 일반적인 가우시안 블러링이 모든 위치에서 일정한 마스크 행렬을 사용하는 것과 차이가 있다. 따라서 양방향 필터는 가우시안 블러링보다 훨씬 많은 연산량을 필요로 한다.
  • OpenCV에서는 bilateralFilter() 함수를 이용하여 양방향 필터를 수행할 수 있다.
    • bilateralFilter() 함수에서 sigmaSpace 값은 일반적인 가우시안 필터링에서 사용하는 표준 편차와 같은 개념이다. 즉, 값이 클수록 더 많은 주변 픽셀을 고려하여 블러링을 수행한다.
    • sigmaColor 값은 주변 픽셀과의 밝기 차이에 관한 표준 편차이다. sigmaColor 값을 작게 지정할 경우, 픽셀 값 차이가 큰 주변 픽셀과는 블러링이 적용되지 않는다. 반면 sigmaColor 값을 크게 지정하면 픽셀 값 차이가 조금 크더라도 블러링이 적용된다. 즉, sigmaColor 값을 이용하여 어느 정도 밝기 차를 갖는 에지를 보존할 것인지를 조정할 수 있다.

미디언 필터

  • 미디언 필터(median filter)는 인력 영상에서 자기 자신 픽셀과 주변 픽셀 값 중에서 중간값(median)을 선택하여 결과 영상 픽셀 값으로 설정하는 필터링 기법
    • 미디언 필터는 마스크 행렬과 입력 영상 픽셀 값을 서로 곱한 후 모두 더하는 형태의 연산을 사용하지 않는다.
    • 미디언 필터는 주변 픽셀 값들의 중간값을 선택하기 위해 내부에서 픽셀 값 정렬 과정이 사용된다.
    • 미디언 필터는 특히 잡음 픽셀 값이 주변 픽셀값과 큰 차이가 있는 경우에 효과적으로 동작한다.
  • 아래 이미지는 3 x 3 정방형 마스크를 사용하는 미디언 필터 동작 방식을 나타낸 것이다.

  • OpenCV에서 medianBlur() 함수를 이용하여 미디언 필터링을 수행할 수 있다.
    • medianBlur() 함수는 ksize x ksize 필터 크기를 이용하여 미디언 필터링을 수행한다.
    • 다채널 영상인 경우 각 채널별로 필터링을 수행한다.
    • medianBlur() 함수는 내부적으로 BORDER_REPLICATE 방식으로 가장자리 외곽 픽셀 값을 설정하여 필터링을 수행한다.