본문 바로가기
프로그래밍/OpenCV

[OpenCV] 머신러닝과 KNearest 클래스

by 별준 2022. 5. 10.

References

Contents

  • OpenCV 머신러닝 클래스
  • k-최근접 이웃(k-NN) 알고리즘

OpenCV의 머신러닝 클래스

OpenCV는 다양한 머신 러닝 알고리즘을 클래스로 구현하여 제공합니다. 제공되는 클래스들은 주로 ml 모듈에 포함되어 있으며, cv::ml::StatModel 추상 클래스를 상속받아 만들어집니다.

StatModel 클래스 이름은 statistical model(통계적 모델)을 의미하며, 이 클래스를 상속받아 만들어진 클래스들의 다이어그램은 위와 같습니다.

 

StatModel 클래스는 머신러닝 알고리즘을 학습시키는 StatModel::train() 멤버 함수와 학습된 모델을 이용하여 테스트 데이터에 대한 결과를 예측하는 StatModel::predict() 멤버 함수를 가지고 있습니다. StatModel을 상속받아 만든 클래스들도 각각의 머신러닝 알고리즘에 해당하는 train()과 predict() 기능을 오버라이드하고 있으며, 몇몇 클래스는 자신만의 학습 및 예측을 위한 함수를 따로 제공하기도 합니다.

 

학습을 수행하는 StatModel::train()는 3가지 종류가 있는데, TrainData 타입으로 훈련 데이터(training data)를 전달하는 방법과 Mat 타입으로 training 데이터를 전달하는 방식으로 나누어져 있습니다. 여기서는 간단하게 살펴보기 위해서 Mat 타입의 입력을 사용하는 방식만 살펴보겠습니다.

위 함수에서는 samples에 저장된 훈련 데이터를 사용하여 머신러닝 알고리즘을 학습합니다. 이때 training data에 대한 label 정보를 responses로 전달합니다. 그리고 Mat 행렬에 훈련 데이터가 어떠한 방식으로 저장되어 있는지에 대한 정보를 layout 인자를 통해 설정하는데, 이 인자는 SampleTypes 열거형 상수 중 하나로 지정합니다.

대부분의 경우 ROW_SAMPLE을 사용하는데, 이는 훈련 데이터가 samples 행렬에 행 단위로 저장되어 있다는 것을 의미합니다.

 

이미 학습된 모델에 대해 테스트 데이터의 응답을 얻고 싶다면 StatModel::predict() 함수를 사용합니다.

이 함수는 순수 가상 함수로써, 각각의 머신러닝 알고리즘 클래스는 자신만의 알고리즘을 이용한 predict를 수행하도록 이 함수를 오버라이드하고 있습니다. 일부 클래스는 predict()를 대신할 고유의 함수를 제공하기도 합니다.

 


k-최근접 이웃 알고리즘

k-최근접 이웃(kNN, k-Nearest Neighbor) 알고리즘은 분류 또는 회귀에 사용되는 지도 학습 알고리즘의 하나입니다. 이를 분류에 사용하는 경우, 특정 공간에서 테스트 데이터와 가장 가까운 k개의 훈련 데이터를 찾고 k개의 훈련 데이터 중에서 가장 많은 클래스를 테스트 데이터의 클래스로 지정합니다. 이 알고리즘을 회귀 문제에 적용하는 경우에는 테스트 데이터에 인접한 k개의 훈련 데이터 평균을 테스트 데이터의 값으로 설정합니다.

 

출처 : 위키백과(k-최근접 이웃 알고리즘)

kNN 알고리즘을 이해하기 위해서 위의 그림을 살펴보겠습니다. 위 그림은 2차원 평면상에 파란색 사각형, 빨간색 삼각형 두 종류의 데이터가 분포되어 있습니다. 이들 파란색과 빨간색 점들이 훈련 데이터이고, 이 훈련 데이터는 두 개의 클래스로 구분됩니다. 각 점들은 (x, y) 좌표로 표현되므로 이 데이터들은 2차원 특징(feature) 공간에 정의되어 있다고 표현할 수 있습니다. 여기에 녹색으로 표시한 새로운 데이터를 추가할 경우, 이 점을 파란색 사각형 클래스에 포함시킬 것인지, 빨간색 삼각형 클래스에 포함시킬 것인지 결정해야 합니다. 간단한 방법은 새로 들어온 점과 가장 가까이 있는 훈련 데이터를 찾아, 해당 훈련 데이터의 클래스로 지정하는 것입니다. 위 그림에서는 빨간색 삼각형 클래스가 가장 가까우므로 녹색 점을 빨간색 삼각형 클래스로 지정할 수 있습니다. 이러한 방법을 최근접 이웃(NN, Nearest Neighbor) 알고리즘이라고 합니다.

 

그러나 위 그림을 조금 더 자세히 살펴보면, 녹색 점 주변에 빨간색 삼각형보다 파란색 사각형이 더 많이 분포하는 것을 알 수 있습니다. 실제로 녹색 점을 중심으로 하는 원을 그려보면 원 안에 빨간색 삼각형보다 파란색 사각형이 더 많이 나타나는 것을 확인할 수 있습니다. 즉, 녹색 점에서 가장 가까운 도형은 빨간색 사각형이지만, 이 지점은 파란색 사각형이 더 많이 분포하는 지역이라고 판단할 수 있습니다. 따라서, 녹색 점을 파란색 사각형으로 지정하는 것이 더 합리적일 수 있으며, 이러한 방식으로 분류하는 방법을 kNN 알고리즘이라고 합니다.

 

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

 

KNearest 클래스

OpenCV에서 kNN 알고리즘은 kNearest 클래스에 구현되어 있습니다. 이 클래스는 ml 모듈에 포함되어 있으며, cv::ml 네임스페이스에 정의되어 있습니다. 이 클래스를 사용하려면 먼저 KNearest 객체를 생성해야 하며, 이는 KNearest::create() 정적 멤버 함수를 사용하여 생성할 수 있습니다.

static Ptr<KNearest> cv::ml::KNearest::create()

이 함수는 단순히 비어 있는 KNearest 객체를 생성하여 Ptr<KNearest> 타입으로 반환합니다. 이 반환값은 KNearest 객체를 참조하는 Ptr 스마트 포인터 객체입니다.

 

KNearest 객체는 기본적으로 k값을 10으로 설정합니다. 이 값을 변경하려면 KNearest::setDefaultK() 함수를 이용하여 변경할 수 있습니다. 만약 StatModel::predict() 함수 대신 KNearest::findNearest() 멤버 함수를 사용하여 테스트 데이터를 predict하는 경우에는 k값을 KNearest::findNearest() 함수 인자로 지정할 수 있습니다.

 

KNearest 객체는 기본적으로 분류를 위한 용도로 생성되는데, 이 객체를 분류가 아닌 회귀에 적용하려면 KNearest::setIsClassifier() 멤버 함수에 false를 지정하여 호출하면 됩니다.

 

KNearest 객체를 생성하고 속성을 설정한 후에는 StatModel::train() 함수를 이용하여 학습할 수 있습니다. 이 클래스의 경우에는 train() 함수에서 실제적인 학습이 진행되는 것은 아니며, 단순히 훈련 데이터와 레이블 데이터를 KNearest 클래스 멤버 변수에 저장하는 작업이 수행됩니다.

이 과정을 수행한 후, 테스트 데이터에 대한 예측을 수행할 때는 주로 KNearest::findNearest() 함수를 사용합니다. StatModel::predict()를 사용할 수도 있지만, KNearest::findNearest() 함수가 예측 결과와 관련된 정보를 더 많이 반환하기 때문에 더 유용합니다.

위 함수는 samples 행렬 각 행에 저장된 테스트 데이터와 가까운 k개의 훈련 데이터를 찾아 분류 또는 회귀 예측을 반환합니다. samples 행렬의 행 개수는 예측할 테스트 데이터 개수와 같고, 열 개수는 학습 시 사용한 훈련 데이터의 차원과 같아야 합니다. 예측 결과가 저장되는 results 행렬은 samples 행렬과 같은 행 개수를 가지고, 열 개수는 항상 1입니다. 따라서, samples 행렬에서 i번째 행에 대한 응답이 results 행렬의 i번째 행에 저장됩니다. neighborResponses와 dist는 kNN 알고리즘 수행 후 추가적인 정보를 받아오는 용도이며, 필요하지 않으면 생략 가능합니다.

 

아래 예제 코드는 2차원 평면에서 3개의 클래스로 구성된 점들을 kNN 알고리즘으로 분류하고, 그 경계면을 화면에 표시합니다.

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

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

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

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

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

    for (int r = 0; r < img.rows; r++) {
        for (int c = 0; c < img.cols; c++) {
            cv::Mat sample = (cv::Mat_<float>(1, 2) << r, c);

            cv::Mat res;
            knn->findNearest(sample, k_value, res);

            int response = cvRound(res.at<float>(0, 0));
            if (response == 0) {
                img.at<cv::Vec3b>(r, c) = cv::Vec3b(128, 128, 255); // R
            }
            else if (response == 1) {
                img.at<cv::Vec3b>(r, c) = cv::Vec3b(128, 255, 128); // G
            }
            else if (response == 2) {
                img.at<cv::Vec3b>(r, c) = cv::Vec3b(255, 128, 128); // B
            }
        }
    }

    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) {
            cv::circle(img, cv::Point(x, y), 5, cv::Scalar(0, 0, 128), -1, cv::LINE_AA);
        }
        else if (l == 1) {
            cv::circle(img, cv::Point(x, y), 5, cv::Scalar(0, 128, 0), -1, cv::LINE_AA);
        }
        else if (l == 2) {
            cv::circle(img, cv::Point(x, y), 5, cv::Scalar(128, 0, 0), -1, cv::LINE_AA);
        }
    }

    cv::imshow("knn", img);
}

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

int main(int argc, char* argv[])
{
    img = cv::Mat::zeros(cv::Size(500, 500), CV_8UC3);
    knn = cv::ml::KNearest::create();

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

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

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

    cv::randn(rn, 0, 50);
    for (int i = 0; i < NUM; i++) {
        addPoint(cv::Point(rn.at<int>(i, 0) + 250, rn.at<int>(i, 1) + 400), 2);
    }

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

    trainAndDisplay();

    cv::waitKey();
}

위 코드의 실행 결과는 다음과 같습니다.

 

필기체 숫자 인식

OpenCV에는 5천 개의 필기체 숫자가 있는 데이터를 제공합니다. OpenCV 소스 코드가 설치된 폴더에서 '/sources/samples/data' 폴더에 있는 digits.png 파일은 0부터 9까지의 필기체 숫자가 5천개가 적혀있습니다. 

이 digits.png 숫자 이미지에는 0부터 9까지의 숫자가 각각 가로로 100개 세로로 다섯 개씩 적혀 있습니다. 각각의 숫자는 20x20 픽셀 사이즈이며, 전체 크기는 2000x1000 입니다. 이번에는 이 데이터를 이용하여 kNN을 통해 필기체 숫자를 인식해보도록 하겠습니다.

 

보통 머신러닝으로 영상을 인식 또는 분류하는 경우, 영상으로부터 인식에 적합한 feature 벡터를 추출하여 입력으로 사용하는데, 여기서는 간단하게 20x20 픽셀 값 자체를 kNN 알고리즘의 입력으로 사용합니다.

 

숫자 이미지의 픽셀값 자체를 이용하여 KNearest 훈련 데이터 행렬을 만드는 과정은 다음과 같습니다.

한 장의 숫자 이미지는 20x20 픽셀 사이즈이고, 이 픽셀을 모두 일렬로 늘여놓으면 1x400 사이즈의 행렬로 변환할 수 있습니다. 즉, 필기체 숫자 하나는 400개의 숫자 값으로 표현되고, 이는 400차원 공간에서의 한 점과 같습니다. digits.png에 있는 각각의 숫자를 1x400 행렬로 바꾸고, 이 행렬을 모두 세로로 쌓으면 전체 데이터를 표현하는 5000x400 크기의 행렬을 만들 수 있고 이 행렬을 KNearest 클래스의 훈련 데이터로 전달합니다.

훈련 데이터에 맞는 레이블 행렬도 함께 전달해야 하는데, 이 레이블 행렬의 행 크기는 훈련 데이터와 같고, 열 크기는 1입니다.

 

digits.png로부터 훈련 데이터 행렬과 레이블 데이터 행렬을 만들고, KNearest 객체를 학습시키는 코드는 다음과 같습니다.

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

cv::Ptr<cv::ml::KNearest> train_knn()
{
    cv::Mat digits = cv::imread("digits.png", cv::IMREAD_GRAYSCALE);

    if (digits.empty()) {
        std::cout << "Image load failed!\n";
        return 0;
    }

    cv::Mat train_images, train_labels;

    for (int i = 0; i < 50; i++) {
        for (int j = 0; j < 100; j++) {
            cv::Mat roi, roi_float, roi_flatten;
            roi = digits(cv::Rect(j * 20, i * 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(i / 5);
        }
    }

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

    return knn;
}

void on_mouse(int event, int x, int y, int flags, void* userdata)
{
    static cv::Point ptPrev(-1, -1);

    cv::Mat img = *(cv::Mat*)userdata;

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

int main(int argc, char* argv[])
{
    auto knn = train_knn();

    if (knn.empty()) {
        std::cout << "Training failed!\n";
        return -1;
    }

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

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

    while (true) {
        int c = cv::waitKey();

        if (c == 27) {
            break;
        }
        else if (c == ' ') {
            cv::Mat img_resize, img_float, img_flatten, res;

            cv::resize(img, img_resize, cv::Size(20, 20), 0, 0, cv::INTER_AREA);
            img_resize.convertTo(img_float, CV_32F);
            img_flatten = img_float.reshape(1, 1);

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

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

위 코드는 사용자가 이미지 위에 숫자를 쓸 수 있도록 마우스 콜백 함수를 등록하는 setMouseCallback()을 호출합니다. 그리고 사용자가 숫자를 그린 뒤, space키를 누르면 사용자가 그린 글씨를 인식하여 콘솔 창에 인식 결과를 출력합니다.

 

실행 결과는 다음과 같습니다.

'프로그래밍 > OpenCV' 카테고리의 다른 글

[OpenCV] dnn 모듈  (0) 2022.05.12
[OpenCV] 서포트 벡터 머신 (SVM)  (0) 2022.05.11
[OpenCV] 컬러 이미지 처리  (0) 2022.05.09
[OpenCV] 에지 검출  (0) 2022.05.08
[OpenCV] 어파인 변환 / 투시 변환  (0) 2022.05.07

댓글