이 글에서는 OpenCV를 활용하여 실시간 카메라 영상에서 안구(눈동자)의 움직임을 감지하고 그 위치를 추적하는 방법을 알아봅니다. 아래 예제 프로그램은 웹캠 영상에서 얼굴과 눈을 검출한 뒤, 하프 변환(Hough Transform) 기법으로 안구의 정확한 위치를 찾아 화면에 표시합니다.
전체 동작 흐름
- 얼굴 검출: Haar 캐스케이드 분류기(
haarcascade_frontalface_alt.xml)로 프레임에서 얼굴 영역을 찾습니다. - 눈 검출: 검출된 얼굴 영역 안에서 눈 전용 캐스케이드(
haarcascade_eye.xml)를 사용해 두 개의 눈을 찾습니다. - 왼쪽 눈 선택: x 좌표가 가장 작은(화면상 왼쪽에 있는) 눈 하나만 선택하여 처리량을 줄입니다.
- 원 검출:
HoughCircles()함수로 눈 영역 내부의 후보 원(각막·동공)들을 검출합니다. - 안구 판별 및 안정화: 후보 원들 중 내부 픽셀 밝기 합이 가장 작은 원(가장 어두운 영역, 즉 동공)을 안구로 판단하고, 최근 여러 프레임의 평균값으로 결과를 부드럽게 안정화합니다.
핵심 함수 설명
1. eyeBallDetection()
검출된 각 후보 원 내부에 속하는 픽셀들의 밝기 값을 모두 더합니다. 동공은 주변보다 훨씬 어두운 영역이므로, 픽셀 합이 가장 작은 원이 곧 안구라고 판단할 수 있습니다.
2. detectLeftEye()
검출된 두 눈 중 왼쪽 위(top-left) x 좌표가 가장 작은 눈의 사각형 정보를 반환합니다.
3. makeStable()
최근 N개 프레임(여기서는 5개)의 안구 중심 좌표 평균을 계산하여 반환합니다. 이렇게 하면 한 프레임의 노이즈로 인해 추적점이 급격히 흔들리는 현상을 막을 수 있습니다.
4. eyeDetection()
그레이스케일 변환 → 히스토그램 평활화 → 얼굴 검출 → 눈 검출 → 하프 원 검출 → 안구 위치 계산까지의 전 과정을 수행하고, 결과를 원본 프레임에 그려줍니다.
예제 코드
#include<iostream>
#include<opencv2/core/core.hpp>
#include<opencv2/highgui/highgui.hpp>
#include<opencv2/imgproc/imgproc.hpp>
#include<opencv2/objdetect/objdetect.hpp>
#include<string>
using namespace cv;
using namespace std;
Vec3f eyeBallDetection(Mat& eye, vector<Vec3f>& circles) {
vector<int> sums(circles.size(), 0);
for (int y = 0; y < eye.rows; y++) {
uchar* data = eye.ptr<uchar>(y);
for (int x = 0; x < eye.cols; x++) {
int pixel_value = static_cast<int>(*data);
for (int i = 0; i < circles.size(); i++) {
Point center((int)round(circles[i][0]), (int)round(circles[i][1]));
int radius = (int)round(circles[i][2]);
if (pow(x - center.x, 2) + pow(y - center.y, 2) < pow(radius, 2)) {
sums[i] = sums[i] + pixel_value;
}
}
++data;
}
}
int smallestSum = 9999999;
int smallestSumIndex = -1;
for (int i = 0; i < circles.size(); i++) {
if (sums[i] < smallestSum) {
smallestSum = sums[i];
smallestSumIndex = i;
}
}
return circles[smallestSumIndex];
}
Rect detectLeftEye(vector<Rect>& eyes) {
int leftEye = 99999999;
int index = 0;
for (int i = 0; i < eyes.size(); i++) {
if (eyes[i].tl().x < leftEye) {
leftEye = eyes[i].tl().x;
index = i;
}
}
return eyes[index];
}
vector<Point> centers;
Point track_Eyeball;
Point makeStable(vector<Point>& points, int iteration) {
float sum_of_X = 0;
float sum_of_Y = 0;
int count = 0;
int j = max(0, (int)(points.size() - iteration));
int number_of_points = points.size();
for (j; j < number_of_points; j++) {
sum_of_X = sum_of_X + points[j].x;
sum_of_Y = sum_of_Y + points[j].y;
++count;
}
if (count > 0) {
sum_of_X /= count;
sum_of_Y /= count;
}
return Point(sum_of_X, sum_of_Y);
}
void eyeDetection(Mat& frame, CascadeClassifier& faceCascade, CascadeClassifier& eyeCascade) {
Mat grayImage;
cvtColor(frame, grayImage, COLOR_BGR2GRAY);
equalizeHist(grayImage, grayImage);
// 얼굴 검출 //
Mat inputImage = grayImage;
vector<Rect> storedFaces;
float scaleFactor = 1.1;
int minimumNeighbour = 2;
Size minImageSize = Size(150, 150);
faceCascade.detectMultiScale(
inputImage, storedFaces, scaleFactor, minimumNeighbour, 0 | CASCADE_SCALE_IMAGE, minImageSize);
if (storedFaces.size() == 0) return;
Mat face = grayImage(storedFaces[0]);
// 검출된 얼굴 주위에 사각형 그리기 //
int x = storedFaces[0].x;
int y = storedFaces[0].y;
int h = y + storedFaces[0].height;
int w = x + storedFaces[0].width;
rectangle(frame, Point(x, y), Point(w, h), Scalar(255, 0, 255), 2, 8, 0);
// 눈 검출 //
Mat faceRegion = face;
vector<Rect> eyes;
float eyeScaleFactor = 1.1;
int eyeMinimumNeighbour = 2;
Size eyeMinImageSize = Size(30, 30);
eyeCascade.detectMultiScale(faceRegion, eyes, eyeScaleFactor, eyeMinimumNeighbour,
0 | CASCADE_SCALE_IMAGE, eyeMinImageSize);
if (eyes.size() != 2) return;
// 눈 주위에 사각형 그리기 //
for (Rect& eye : eyes) {
rectangle(frame, storedFaces[0].tl() + eye.tl(), storedFaces[0].tl() + eye.br(), Scalar(0, 255, 0), 2);
}
// 왼쪽 눈 가져오기 //
Rect eyeRect = detectLeftEye(eyes);
Mat eye = face(eyeRect);
equalizeHist(eye, eye);
// Hough Circles로 눈 영역 내 원 검출 //
Mat hough_Circle_Input = eye;
vector<Vec3f> circles;
int method = 3;
int detect_Pixel = 1;
int minimum_Distance = eye.cols / 8;
int threshold = 250;
int minimum_Area = 15;
int minimum_Radius = eye.rows / 8;
int maximum_Radius = eye.rows / 3;
HoughCircles(hough_Circle_Input, circles,
HOUGH_GRADIENT, detect_Pixel, minimum_Distance, threshold, minimum_Area, minimum_Radius, maximum_Radius);
// 안구를 둘러싸는 원 검출 및 그리기 //
if (circles.size() > 0) {
Vec3f eyeball = eyeBallDetection(eye, circles);
Point center(eyeball[0], eyeball[1]);
centers.push_back(center);
center = makeStable(centers, 5);
track_Eyeball = center;
int radius = (int)eyeball[2];
circle(frame, storedFaces[0].tl() + eyeRect.tl() + center, radius, Scalar(0, 0, 255), 2);
circle(eye, center, radius, Scalar(255, 255, 255), 2);
}
cout << "The location of the eyeball is" << track_Eyeball << endl;
imshow("Eye", eye);
}
int main() {
// 캐스케이드 분류기 로드 //
CascadeClassifier faceCascade;
faceCascade.load("C:/opencv/sources/data/haarcascades/haarcascade_frontalface_alt.xml");
CascadeClassifier eyeCascade;
eyeCascade.load("C:/opencv/sources/data/haarcascades/haarcascade_eye.xml");
// 카메라 피드 캡처 후 eyeDetection 함수 호출 //
VideoCapture cap(0);
Mat frame;
while (1) {
cap >> frame;
eyeDetection(frame, faceCascade, eyeCascade);
imshow("Webcam", frame);
if (waitKey(30) >= 0) break;
}
return 0;
}주요 파라미터 설명
scaleFactor = 1.1: 이미지 피라미드 단계마다 축소되는 비율입니다. 값이 작을수록 검출률은 높아지지만 처리 속도가 느려집니다.minimumNeighbour = 2: 후보 사각형이 유지되기 위해 필요한 최소 이웃 검출 횟수입니다.HoughCircles의threshold = 250: 원 경계의 에지 강도 임계값으로, 값이 클수록 엄격하게 원을 검출합니다.minimum_Radius,maximum_Radius: 눈 크기에 맞는 반지름 범위를 제한하여 오탐을 줄입니다.
실행 결과
프로그램을 실행하면 웹캠 영상에서 얼굴(마젠타색 사각형)과 눈(녹색 사각형)이 표시되고, 빨간색 원이 안구의 현재 위치를 따라다니며 이동하는 것을 확인할 수 있습니다.


마무리
이 예제는 Haar 캐스케이드 기반 객체 검출과 하프 원 변환, 그리고 프레임 간 평균 필터링을 조합하여 간단하면서도 효과적인 안구 추적 시스템을 구현하는 방법을 보여줍니다. 응용하면 시선 추적 인터페이스, 운전자 졸음 감지 시스템, AR 콘텐츠 등 다양한 프로젝트로 확장할 수 있습니다.