Pupil Detection (c/c++)

Instroduction

현재 모바일 기기의 주된 명령 입력 방식은 손가락을 이용한 터치이다. 손가락으로 기기에 입력을 넣고, 우리는 그 결과를 눈 혹은 귀로 결과를 확인한다. 손가락 외에 두 번째 입력 방식은 바로 목소리이다. Siri, S-Voice, 그리고 구글에서 음성 인식을 통해 기기에 명령을 전달할 수 있도록 많은 노력을 기울이고 있다.

사실 난 음성 인식에 대해 비관적이다. 몇 가지 이유가 있는데, 첫 번째로 난 음성 인식 기능을 공개된 장소에서 사용할 정도로 외향적이지 않다. 누군가 내가 스마트폰에 은밀하게 전달하는 명령을 듣는 다는 것은 매우 부끄러운 일이다. 두 번째로 음성 인식은 아직 완벽하지 않다. 많은 발전이 있었지만, 아직도 평상시에 사용하기엔 인식률이 많이 올라오지 못했다고 생각한다. 첫 번째 이유를 이 두 번째 이유로 인해 여러 번 겪어야 한다는 것은 내 비관적 견해를 1+1=2에서 1+1=3으로 만들기 충분하다.

최근 아이폰5S에 지문 인식을 통한 잠금 해제로 간편한 생채 인식 기술이 이슈가 되었다. 지문 인식에 대한 루머가 떠돌 때 우려가 됐었지만, 이 우려를 말끔히 씻어버렸다. 인식 속도도 매우 빠르고 정확도도 많이 끌어올렸으며, 거추장스러운 추가 하드웨어도 탑재되지 않았다. 이처럼 음성 인식도 모범적인 사례로 적용될 날이 올거라 믿는다. 이런 생체 인식 중 내가 가장 호기심이 가는 분야는 시선 추적이다.

정확하게 인식만 된다면, 제2의 입력 도구로 사용되는 음성을 통한 명령보다 신속하고, 직관적이며, 부끄럽지 않게 사용할 수 있다고 생각한다. 이 분야는 이미 마케팅, UX설계 등의 분야에서 활용되고 있다. 이런 분야는 입력 도구라기보다는 분석을 위한 도구로 활용된다고 볼 수 있다.

아직 실생활에서 활용되기는 제한되는 사항들이 있다. 디바이스의 연산 속도, 베터리, 그리고 현재 기술의 정확도이다. 하지만 하드웨어들은 가공할만한 속도로 발전해 나가고 있고, 현재 환경에 안주하다보면 하드웨어는 준비되어가고 있는데 소프트웨어가 준비되어있지 않은 상황이 올 수 있다. 엄청난 연산량과 베터리 광탈 현상이 일어나도 우리는 우리의 할 일을 해 나가면 된다.

Gaze Interface

실제 시선 추적이 적용된 인터페이스 제어의 데모 영상을 보자.

  1. 시선 추적을 위한 초기화 과정
  2. 키보드 버튼을 누르고, 선택을 원하는 위치를 응시한 후, 버튼을 다시 떼어 아이콘을 선택하는 동작 시연
  3. 원하는 곳을 응시하고 터치 패널에 핀치 제스쳐를 통해 응시한 곳을 확대하는 동작 시연

현재 시선 추적 분야에서 가장 눈에 띄게 나아가고 있는 업체가 바로 이 영상에 나오는 Tobii사이다. 윈도우 8부터 활발하게 움직이더니 꽤 많은 발전을 이루어 냈다. 아래 Tobii 하드웨어가 추가된 것으로 보면 IR LED와 적외선 카메라를 활용했을 가능성이 높다고 조심스래 판단해본다. 마음같아선 나도 IR LED를 활용하고 싶지만, 환경이 갖춰지지 않았으니 우선은 카메라와 소프트웨어만을 이용해 진행하려고 한다. 기회가 된다면 꼭 도전해보겠다. 아래 영상은 (아마도)웹캠만을 이용해 시선 추적을 수행한 데모 영상이다.

본격적으로 시작을 해보자. 몇 가지 시도해 보았는데, 비교적 간단한 방법을 사용한 논문을 찾았다.

Pupil Detection

A robust method for eye features extraction on color image- Z.Zheng

Abstract

  • 눈 특징(동공 중심, 반지름, 양 코너, 눈꺼풀 윤곽선)을 추출한다.
  • 동공의 중심을 HSV color 공간에서 H 채널을 추출하고 동공의 반지름을 추정한다.
  • 눈의 코너의 위치를 위해 코너의 포인트를 탐색할 Gabor eye-corner 필터가 설계된다.
  • 이것은 다른 연구인 Projection method나 Edge detection보다 강화되었다.
  • 눈꺼풀 커브 연곽선은 spline function으로 간단하게 피팅시킨다.

Detect Center of Pupil

  • HSV 컬러 공간에서 검출한다.
  • 많은 사람들의 연구에 의해 RGB 컬러 공간보다 일관되기 떄문이다.

HSV 컬러 공간에서의 H 채널

  • 눈동자는 빛에 반응하기 때문에 H 채널에서 눈동자의 밝은 영역이 존재한다.
  • 이 특징은 각기 다른 조도에서 적응되어 사용될 수 있다.
  • 밝은 영역은 Integral projection method를 통해 알아낼 수 있다.

눈동자 중심의 예

  • 눈동자의 중앙은 horizontal integral projection에서 끌어낼 수 있다.(위 그림)
  • 투영 커브의 최대치와 그에 상응하는 좌표가 필요하다.
  • 이것은 좋지 않은 성능을 보이는 임계값을 이용할 필요없다. 최대치를 이용하기 때문이다.
  • 하지만 눈동자 위치가 아직 정확하지 않기때문에 재정의할 필요가 있다.
  • 눈동자는 원이며, 주변보다 어둡다는 특징을 갖고 있다.
  • Vezhnevets & Degtiareva 는 알고리즘을 적용하고 눈동자의 반지름을 추정할 수 있도록 수정하였다.
  • 대부분 원지름이 큰 d/dr*f가 된다.
  • 우리는 이 방법이 강건하지 않다는 것을 인지했다.
  • 그 이유는 반지름의 원을 찾기 위해 반지름의 임계값을 정해야 하기 때문이다.
  • 이 임계값은 실제 눈동자의 반지름과 다를 가능성이 있다.
  • 또한, 조명이 변화게 되면 이 임계값은 동일하게 유지되어선 안된다.
  • 본 논문에서는 gray level에서 눈동자의 반지름을 추정한다.
  • 대략적인 눈동자의 위치가 주어지면, 반지름이 설정된 원이 정해진다.
  • 우리는 이것을 눈동자의 Location initialization이라고 부르겠다.
  • 원형 탐색 방법은 2가지 스탭으로 나뉜다.
  • 첫 단계는 Circle shift 이다.
  • 이 단계의 연산은 주어진 위치를 포함하는 주변을 Shift시켜, Gray level에서 평균 수치가 가장 낮은 위치를 찾는다.
  • 픽셀의 평균 gray값을 계산하기 전에 눈동자의 중심이 강조되는 부분을 추정할 middle filter가 적용된다.
  • 두 번째 단계는 원을 확장하거나 움추려들도록 하는 단계이다.
  • 첫 단계를 수행한 후에 원의 중심은 동공의 중심과 가까워진다.
  • 여기서 불완전한 점은 원의 반지름이 정확하지 않다는 것이다.
  • 이와 같은 이유로 원을 확장하거나 수축시켜 크기를 맞춘다.
  • 확장, 팽창 또한 평균 gray level 픽셀 값을 통해 구한다. 아래 그림이 눈동자를 원에 맞춘 결과이다.

위 과정과 유사하게 구현한 결과는 아래와 같다. 위 방법론을 그대로 구현하진 않았고, 낮은 레벨에서 흉내낼 수 있을 정도로 구현되었다.
(OpenCV 환경이 갖춰져 있어야 한다.)


//
//  main.cpp
//  eye_detector
//
//  Created by Seonwoon Kim on 13. 5. 28..
//  Copyright (c) 2013년 Seonwoon Kim. All rights reserved.
//
#include 
#include "eye_detector.h"

#define LOG 0

using namespace std;

int main(){

    // Create cascade
    cv::CascadeClassifier cascade;
    if(!cascade.load(FILENAME_CASCADE)){
        std::cout << "Please check the cascade" << std::endl;
        return 0;
    }

    // Create an image to store the video screen grab
    cv::Mat image, gray, thresh, temp, tempResult;
    cv::Rect pupilRoi, eyeRoi;

    // Create result
    cv::Point pupilCenter;

    // Setup the video capture method using the default camera
    cv::VideoCapture cap;
    cap.open(0);
    cap.set(CV_CAP_PROP_FRAME_WIDTH, FRAME_WIDTH);
    cap.set(CV_CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT);

    // Create the window that will show the video feed
    cv::namedWindow(WINDOWNAME_IMAGE, CV_WINDOW_AUTOSIZE);

    // Contour
    cv::vector> contours;
    cv::vector hierarchy;

    // Be draw informacion storage
    // char text[256];

    // Create a loop to update the image with video camera image capture
    bool needInit = true;
    while(1){
        // Grad a frame from the video camers
        cap >> image;
        cv::cvtColor(image, gray, CV_RGB2GRAY);
        cv::equalizeHist(gray, gray);
        if(needInit){  // Detect area using haar cascade
            pupilRoi = getEyeROI(gray, cascade);
            if((pupilRoi.width*pupilRoi.height) != 0){
#if LOG
                cout << "Eye detected" << endl;
#endif
                // Using harf of roi for tracking an eye
                pupilRoi.width /= 2.5;

                // Draw result ractangle
                cv::rectangle(image, pupilRoi, CV_RGB(0,0,255));

                // Create template image
                temp = gray(pupilRoi).clone();
                // Expansion ROI
                eyeRoi.x = pupilRoi.x - ROI_PADDING;
                eyeRoi.y = pupilRoi.y - ROI_PADDING;
                eyeRoi.width = pupilRoi.width + (ROI_PADDING*2);
                eyeRoi.height = pupilRoi.height + (ROI_PADDING*2);
                if(eyeRoi.x < 0) eyeRoi.x = 0;
                else if(eyeRoi.x + eyeRoi.width > image.cols)
                    eyeRoi.x = image.cols - eyeRoi.width;
                if(eyeRoi.y < 0) eyeRoi.y = 0;
                else if(eyeRoi.y + eyeRoi.height > image.rows)
                    eyeRoi.y = image.rows - eyeRoi.height;
#if LOG
                cout << "Set ROI" << endl;
#endif
                // Set tracking mode
                needInit = false;
            }
        }else{  // Detect area using template matching and detect pupil
            if(updateRoi(gray, temp, tempResult, eyeRoi, pupilRoi)){
                // Detect center of pupil
                pupilCenter = getPupilCenter(image, pupilRoi);
#if LOG
                cout << "Get pupil center" << endl;
#endif
                // Shifting pupil of center
                pupilCenter = shiftToCenter(gray, pupilCenter, SIZE_PUPIL_DEFAULT);
#if LOG
                cout << "Shifting center" << endl;
#endif
                // Get radius
                int radius = getRadiusOfPupil(
                    image, gray,pupilCenter,SIZE_PUPIL_DEFAULT/2,SIZE_PUPIL_DEFAULT*2);
#if LOG
                cout << "Get radius of pupil" << endl;
#endif
                // Draw roi
                image(pupilRoi).copyTo(image(cv::Rect(0,0,pupilRoi.width,pupilRoi.height)));
                cvtColor(gray(pupilRoi), 
                                image(cv::Rect(0,pupilRoi.height,pupilRoi.width,pupilRoi.height)),
                                CV_GRAY2RGB);

                // Draw result
                cv::circle(image, pupilCenter, 2, CV_RGB(255,255,0), -1);
                cv::circle(image, pupilCenter, radius, CV_RGB(0,0,255), 1);
                cv::rectangle(image, pupilRoi, CV_RGB(0, 255, 0));
                cv::rectangle(image, eyeRoi, CV_RGB(255, 0, 0));

            }else{  // Not detected
                needInit = true;
#if LOG
                cout << "reset" << endl;
#endif
            }
        }

        // Show the image on the screen
        cv::imshow(WINDOWNAME_IMAGE, image);

        // Create a 10ms delay and receive key event
        char c = cv::waitKey(10);
        if(c == KEY_ESC)
            break;
        else if(c == KEY_RESET)
            needInit = true;
        else if(c == ' ')
            cout << pupilCenter.x << "," << pupilCenter.y << endl;
    }

    // Release objects
    cv::destroyAllWindows();
    cap.release();
    image.release(); gray.release(); thresh.release();
    temp.release(); tempResult.release();
    return 0;

}

// Extract pupil center
cv::Point getPupilCenter(cv::Mat &image, cv::Rect roi){
    cv::Point pupilCenter;
    cv::Mat hsv, hue, sat, val;
    // RGB to HSV
    cv::cvtColor(image(roi).clone(), hsv, CV_RGB2HSV);

    // Hue hue channel
    if(!hue.data)
        hue.release();
    hue.create( hsv.size(), hsv.depth() );

    // Create saturation channel
    if(!sat.data)
        sat.release();
    sat.create( hsv.size(), hsv.depth() );

    // Create hue channel
    if(!val.data)
        val.release();
    val.create( hsv.size(), hsv.depth() );

    // Extract channels
    cv::Mat out[] = {hue, sat, val};
    int from_to[] = { 0,0, 1,1, 2,2 };
    cv::mixChannels( &hsv, 1, out, 3, from_to, 3 );

    // Estimate center of pupil to max of sat
    double minVal; double maxVal; cv::Point minLoc; cv::Point maxLoc;
    cv::minMaxLoc(sat, &minVal, &maxVal, &minLoc, &maxLoc, cv::Mat());
    pupilCenter.x = (int)maxLoc.x+roi.x;
    pupilCenter.y = (int)maxLoc.y+roi.y;

    // Display saturation channel
    cvtColor(sat, image(cv::Rect(0,roi.height*2,roi.width,roi.height)), CV_GRAY2RGB);
    cv::imshow("hue", hue);
    cv::imshow("val", val);
    hue.release(); sat.release(); val.release();
    return pupilCenter;

}

// Extracted pupil center using hsv shift to real center
cv::Point shiftToCenter(cv::Mat &gray, cv::Point center, int radius){
    cv::Rect currentCenterRect(center.x - radius/2, center.y - radius/2, radius, radius);
    cv::Rect shiftingRect(0,0,radius, radius);
    cv::Point resultCenter = center;
    int minMeanVal = 255;
    cv::Rect finalRect(0,0,0,0);

    // Shift y-axis
    for (int shiftY = -radius/2; shiftY < radius/2; shiftY++) {
        // Shift x-axis
        for (int shiftX = -radius/2; shiftX < radius/2; shiftX++) {
            shiftingRect.x = currentCenterRect.x + shiftX;
            shiftingRect.y = currentCenterRect.y + shiftY;
            if((shiftingRect.x < 0) || (shiftingRect.y < 0)
               || (shiftingRect.x+shiftingRect.width > gray.cols)
               || (shiftingRect.y+shiftingRect.height > gray.rows))
                continue;
            int meanVal = cv::mean(gray(shiftingRect))[0];
            if(minMeanVal > meanVal){
                minMeanVal = meanVal;
                finalRect = shiftingRect;
            }
        }
    }
    // Return center of final rect
    resultCenter.x = finalRect.x+radius/2;
    resultCenter.y = finalRect.y+radius/2;
    return resultCenter;
}



// Extract radius of pupil
int getRadiusOfPupil(cv::Mat &image, cv::Mat &gray, cv::Point ¢er, int min, int max){
    cv::Mat temp;
    cv::Rect pupilRect;
    int resultRadius = min;
    int minMeanVal = 255;
    for(int radius = min; radius <= max; radius++){
        pupilRect.x = center.x - radius/2;
        pupilRect.y = center.y - radius/2;
        pupilRect.width = radius;
        pupilRect.height = radius;
        if(pupilRect.x < 0) pupilRect.x = 0;
        else if(pupilRect.x + pupilRect.width > image.cols)
            pupilRect.x = image.cols - pupilRect.width;
        if(pupilRect.y < 0) pupilRect.y = 0;
        else if(pupilRect.y + pupilRect.height > image.rows)
            pupilRect.y = image.rows - pupilRect.height;

        int meanVal = cv::mean(gray(pupilRect))[0];
        if(minMeanVal >= meanVal){
            minMeanVal = meanVal;
            resultRadius = radius;
        }
    }

    return resultRadius;
}


// Detect ROI of pupil
bool updateRoi(cv::Mat &gray, cv::Mat &temp, cv::Mat &tempResult, cv::Rect &eroi, cv::Rect &proi){
    // Create result image of template matching
    tempResult.create(eroi.width-temp.cols+1, eroi.height-temp.rows+1, CV_32FC1);

    // Template matching
    cv::matchTemplate(gray(eroi), temp, tempResult, CV_TM_CCOEFF_NORMED);
    double minVal; double maxVal; cv::Point minLoc; cv::Point maxLoc;
    cv::minMaxLoc(tempResult, &minVal, &maxVal, &minLoc, &maxLoc, cv::Mat());

    if(maxVal > TM_MAX_VALUE){
        // Move pupil ROI
        proi.x = maxLoc.x+eroi.x;
        proi.y = maxLoc.y+eroi.y;
        proi.width = temp.cols;
        proi.height = temp.rows;
        if(proi.x < 0) proi.x = 0;
        if(proi.y < 0) proi.y = 0;
        if(proi.x+proi.width > gray.cols)
            proi.x = gray.cols-proi.width;
        if(proi.y+proi.height > gray.rows)
            proi.y = gray.rows-proi.height;
        // Move eye ROI
        eroi.x = proi.x - ROI_PADDING;
        eroi.y = proi.y - ROI_PADDING;
        if(eroi.x < 0) eroi.x = 0;
        else if(eroi.x + eroi.width > gray.cols)
            eroi.x = gray.cols - eroi.width;
        if(eroi.y < 0) eroi.y = 0;
        else if(eroi.y + eroi.height > gray.rows)
            eroi.y = gray.rows - eroi.height;

        return true;
    }else{
        std::cout << "Reset" << std::endl;
        return false;
    }
}

// Detetct eye roi and return cv::Rect
cv::Rect getEyeROI(cv::Mat &gray, cv::CascadeClassifier &cascade){

    // Result rect
    std::vector faces;

    // Detect object
    cascade.detectMultiScale(gray, faces, 1.1, 2, 0|CV_HAAR_SCALE_IMAGE, 
        cv::Size(FRAME_WIDTH/10, FRAME_HEIGHT/10));

    // Find maximum rectangle
    int detectCount = (int)faces.size();
    cv::Rect maxRect(0,0,0,0);
    for(int i = 0; i <  detectCount; i++){
        if(maxRect.width * maxRect.height < faces[i].area())
            maxRect = faces[i];
    }
    return maxRect;
}

위 코드는 논문의 내용과 어느 정도 유사하게 구현을 했다. 구동시켜보면 환경에 영향을 꽤 많이 받는 모습을 볼 수 있다. 어떤 환경에선 꽤 만족스러운 결과를 보이지만, 어떤 환경에선 탐색률이 매우 좋지 않다.
아래 영상이 위 코드를 구동시킨 결과 중 어느 정도 추적 성공율이 좋았던 결과를 꼽은 영상이다.

보다시피 잘 될 때도 있고, 잘 되지 않을 때도 있다. 안정도를 높일 필요가 있어 보인다. 이러한 과정과 같이 눈동자 추적에 관련된 논문을 읽고 유사하게나마 구현을 하고 손보면 어느 정도의 결과를 얻을 수 있다.