MAT4015 · HANYANG UNIV · 2016 SPRING

응용확률및랜덤프로세스

OpenCV API 나열이 아니라 직접 구현한 추적기가 맞다. 다만 확률적 근거가 같은 레포에 들어 있는 강의자료와 어긋나 있고, 비교 대상으로 세운 OpenCV 베이스라인은 Rect를 값으로 넘기는 바람에 단 한 프레임도 추적하지 않는다.

저자 코드646줄 / 3파일
커밋32
기간2016.04–06
스택C++ / OpenCV 3.0
레포 크기699 MB
C종합
소견4 치명적6 중대8 경미합계 18

총평

확률 과목의 제출물이 OpenCV 객체 추적이라는 점부터 짚고 시작하자. docs/에 들어 있는 두 PDF는 이상화 교수의 강의자료(mean_shift.pdf 46쪽, 표지에 Comaniciu & Meer, IEEE T. PAMI 2002 인용, "[2016년 1학기 확률및통계]" / image_processing.pdf 39쪽)이고, object_tracking.pptx 18장이 학생 발표자료다. 즉 mean shift를 커널 밀도 추정의 모드 탐색으로 유도하는 수업을 듣고, 그것을 영상 추적으로 구현해 오라는 과제다. 판정의 핵심은 하나다 — 그 확률적 근거가 코드에 살아 있는가.

먼저 가장 중요한 사실부터. 이것은 라이브러리 호출 나열이 아니다. cv_tracker.cc는 한 파일 안에 두 개의 추적기를 나란히 돌린다. 하나는 myHistogram / mySimilarity / 직접 짠 반복 탐색 루프 / 원시 포인터 픽셀 접근(matrixAt)으로 이루어진 자작 구현이고, 다른 하나는 calcHistcalcBackProjectmeanShift를 부르는 OpenCV 베이스라인이다. 같은 창에 초록 박스(자작)와 빨간 박스(OpenCV)를 함께 그려 비교하려 한 설계는 이 과제에서 옳은 실험 감각이다. 히스토그램·역투영·반복 이동이라는 뼈대도 제자리에 있다.

그런데 그 비교가 성립하지 않는다. cvMeanShiftRect를 값으로 받기 때문에 OpenCV가 갱신한 위치는 호출자에게 돌아오지 않는다. 베이스라인은 매 프레임 최초 ROI에서 다시 시작하며, 목표를 따라가지 않는다. 레포에 커밋된 데모 GIF가 이것을 그대로 보여준다 — 초록 박스는 대상을 따라 화면을 가로지르는데 빨간 박스는 제자리에서 떨고만 있다. 프로젝트 전체의 주장인 "내 방법 vs OpenCV"가 의도치 않게 조작된 셈이다.

그리고 확률적 근거는 강의자료와 어긋나 있다. myHistogram은 히스토그램을 합이 아니라 L2 노름으로 나눈다. 결과는 단위벡터이지 확률질량함수가 아니다. mySimilarity는 두 벡터의 단순 내적을 유사도로 쓴다. Bhattacharyya 계수 Σ√(p·q)의 제곱근이 빠져 있다. 커널 프로파일(Epanechnikov/Gaussian)은 코드 어디에도 없어서, 강의자료가 46쪽에 걸쳐 세우는 밀도 추정의 토대 자체가 구현에 존재하지 않는다. 발표자료 9장이 "Modification version of Bhattacharyya distance"라고 스스로 밝히고 있으니 의도적 변형이긴 하나, 하필 확률분포 간 거리를 성립시키는 두 요소를 동시에 버린 변형이다. 같은 레포의 example/open.c:353에는 정석대로 sqrt(histogram[i] * next_frame_histogram[i])가 들어 있다.

여기에 좌표 규약이 두 군데서 엇갈리는 문제가 겹친다. mean shift 루프의 결과는 사실상 버려지고, 화면에 그려지는 박스는 "박스 크기 자동조정" 단계가 우연히 결정한다. 정지 화면에 정답 위치로 초기화해도 박스가 흘러간다(아래 수치 검증). 학부 과제 기준에서 자작 구현을 해냈다는 사실은 분명한 가점이지만, 그 구현이 이 과목이 가르친 확률론의 어느 부분도 정확히 반영하지 못한다는 점이 감점의 전부다.

모듈내용핵심 판정등급
A자작 mean-shift 추적기
cv_tracker.cc "my method"
직접 구현은 진짜다. 다만 확률적 근거가 틀렸고 루프 출력이 버려진다C+
BOpenCV 베이스라인
cv_tracker.cc "openCV method"
Rect 값 전달로 추적이 아예 일어나지 않음. CamShift 분기는 도달 불가D
C프레임 루프 / ROI 입력
main.cc
역방향 드래그가 가드를 통과해 예외로 죽음. 추적 실패 복구 없음C-
D문서
docs/object_tracking.pptx
구조는 성실하고 변형을 자백한 점은 정직. 13장은 구현보다 앞서감B-
E레포 위생699MB. OpenCV SDK 전체 + 60MB avi 커밋, LICENSE 없음F

A — 자작 mean-shift 추적기

평가 대상 코드는 cv_tracker.cc 348줄, main.cc 180줄, cv_tracker.h 118줄로 합계 646줄이다. 레포의 C/C++ 총합 81,973줄 중 나머지 약 81,000줄은 벤더링된 OpenCV 헤더(include/opencv2/, include/opencv/)이므로 저자 작업량이 아니다. 그 646줄 중 자작 추적기는 Tracker::run의 첫 블록(cv_tracker.cc:35–117)과 myHistogram / mySimilarity / matrixAt에 해당한다.

치명적

히스토그램이 확률분포가 아니고, 유사도가 Bhattacharyya 계수가 아니다

정규화가 total += hists[i] * hists[i]sqrt, 즉 L2 노름이다. 이렇게 나누면 Σpi = 1이 성립하지 않으므로 결과는 확률질량함수가 아니라 단위벡터다. 이어서 유사도는 hists[i] * nHists[i]의 합, 곧 두 단위벡터의 내적(코사인 유사도)이다.

강의자료가 다루는 Bhattacharyya 계수는 ρ(p,q) = Σ√(piqi)이고, 이 값이 [0,1]에 갇히고 √(1−ρ)가 거리가 되는 근거는 p와 q가 확률분포라는 데서 나온다. 제곱근을 빼고 L1 정규화를 L2로 바꾸면 그 근거가 둘 다 사라진다. 남는 것은 추적에 쓸 수는 있지만 확률론적 해석이 없는 벡터 내적이다.

결정적인 대조는 같은 레포 안에 있다. example/open.c는 정석 형태를 그대로 갖고 있다.

imageProcessing/imageProcessing/cv_tracker.cc:159–164, 144–145
	// myHistogram — L1이 아니라 L2로 정규화
	double total = 0;
	for (int i = 0; i < param.hist_bins; ++i)
		total += hists[i] * hists[i];
	total = sqrt(total);
	for (int i = 0; i < param.hist_bins; ++i)
		hists[i] /= total;

	// mySimilarity — 제곱근 없는 내적
	for (int i = 0; i < param.hist_bins; ++i)
		similarity += hists[i] * nHists[i];
imageProcessing/example/open.c:351–354 · 같은 레포의 정석 구현
	for (int i = 0; i < 16; i++)
	{
		w += sqrt(histogram[i] * next_frame_histogram[i]);
	}

더불어 커널 프로파일이 없다. 코드 전체에 Epanechnikov도 Gaussian도, 중심에서 멀어질수록 가중치를 줄이는 어떤 공간 가중도 존재하지 않는다. Comaniciu & Meer의 mean shift 벡터가 커널 미분에서 유도된다는 점을 생각하면, 이것은 구현 세부의 누락이 아니라 방법의 토대가 빠진 것이다.

치명적

좌표 규약이 두 곳에서 엇갈려, mean shift 루프의 결과가 버려진다

후보 윈도를 만드는 :52Rect(x, y, w, h)의 첫 두 인자, 즉 좌상단 자리에 temp.x + temp.width/2를 넣는다. 그런데 이 값은 계산해 보면 nRect.x + nRect.width/2, 곧 객체의 중심이다. 따라서 nX는 "좌상단 좌표들의 가중평균"이 된다.

루프를 닫는 :71은 그 nX중심으로 보고 nX - width/2를 한다. 반면 루프 직후 박스 크기를 고르는 :79는 같은 nX좌상단으로 보고 그대로 쓴다. 둘 중 하나만 맞을 수 있다.

// :52  중심 좌표를 Rect의 "좌상단" 자리에 넣는다
w = mySimilarity(hsv, Rect(temp.x + (temp.width / 2) + i, temp.y + (temp.height / 2) + j, nRect.width, nRect.height), this->objectHists);

// :71  nX를 "중심"으로 간주해 절반을 뺀다
nRect = Rect(max((int)nX - nRect.width / 2, 0), max((int)nY - nRect.height / 2, 0), myRect.width, myRect.height);

// :79  같은 nX를 이번엔 "좌상단"으로 쓴다 — 위와 모순
w = mySimilarity(hsv, temp = Rect(nX, nY, i, j), this->objectHists);

결과를 확인하려고 :42–91의 산술을 그대로 Python으로 옮겨 합성 프레임(320×240, 객체 (100,80,60,50))에서 돌렸다. 정지 화면에 정답 위치로 초기화했으므로 올바른 추적기라면 영원히 (100,80)에 머물러야 한다.

// cv_tracker.cc:42-91 산술을 포팅해 실행한 결과
frame  1: mean shift 루프 출력(:71) = (70, 54)   그려진 박스(:83) = (100, 79, 55, 51)
frame  2: mean shift 루프 출력(:71) = (75, 54)   그려진 박스(:83) = (102, 79, 52, 51)
frame  3: mean shift 루프 출력(:71) = (78, 54)   그려진 박스(:83) = (104, 79, 49, 51)
frame  5: ...                                   그려진 박스        = (106, 79, 47, 51)
frame 20: ...                                   그려진 박스        = (106, 79, 48, 51)

정답 = (100, 80, 60, 50)   half-box = (30, 25)

두 가지가 드러난다. 첫째, mean shift 루프 자체는 정확히 박스 절반만큼 어긋난 (70,54)에 수렴한다 — (100,80)에서 (30,25)를 뺀 값이다. 둘째, 화면에 그려지는 박스가 그래도 객체 위에 있는 이유는 :83nRect = temp;가 루프 결과를 덮어쓰기 때문이다. 즉 실제 위치 결정은 mean shift가 아니라 "박스 크기 자동조정" 단계가 하고 있고, mean shift 루프의 출력은 다음 반복의 탐색 중심으로만 쓰이다 폐기된다. 헤더가 자랑하는 부가기능(cv_tracker.h:7 "tracking object autosizing")이 본 알고리즘을 대체해 버린 셈이다.

부수 효과도 측정된다. 완전한 정지 화면인데도 박스가 x로 6px 밀리고 폭이 60에서 45–55 사이로 줄었다 늘었다 한다. 탐색창이 절반 어긋난 위치를 기준으로 잡히기 때문이다.

중대

반복 상한이 없고, 수렴 판정 기준을 루프 안에서 스스로 줄인다

TrackerParam::max_itrs = 32(cv_tracker.h:56)은 정의돼 있지만, 코드 전체에서 이 값을 읽는 곳은 :331:339, 즉 OpenCV TermCriteria뿐이다. 자작 루프에는 반복 상한이 없다.

대신 종료 조건은 이동거리가 param.search_range 이하가 되는 것인데, 그 search_range를 루프 안에서 :69가 매 반복 줄인다. 새 계수는 twRatio > 0.25이면 1 - twRatio(최대 0.75), 아니면 0.75 + twRatio(최대 1.0)이다. 어느 쪽이든 1을 넘지 못하므로 탐색 반경은 단조 감소한다. 매 반복 임계값이 좁아지는 종료 조건이라 수렴 보장이 없다.

// :69  계수가 절대 1을 넘지 않는다 → 탐색 반경 단조 감소
param.search_range *= (twRatio > (EXTEND_LIMIT) ? 1 - twRatio : (1 - EXTEND_LIMIT) +twRatio);
// :72  그 줄어든 값이 곧 수렴 임계값
} while (sqrt(pow(myRect.x - nRect.x, 2) + pow(myRect.y - nRect.y, 2)) > param.search_range);
// :91  하한 클램프는 루프가 끝난 뒤에야 걸린다
param.search_range = min(max(param.search_range, SEARCH_MIN), SEARCH_MAX);

하한 SEARCH_MIN = 4:91, 즉 루프를 빠져나온 뒤에만 적용된다. 루프 안에서는 반경이 0에 임의로 가까워질 수 있다. 위 시뮬레이션에서 search_range는 1프레임 만에 하한 4.0에 붙어 그 뒤로 움직이지 않았고, 이는 헤더가 내건 "dynamic search range"(cv_tracker.h:6)가 사실상 무력화되었다는 뜻이다.

더 나쁜 잠재 위험이 :48에 있다. temp.width / param.sampling은 양쪽이 int이므로 정수 나눗셈이고, temp.width < 8이면 보폭이 0이 된다. 그러면 :49i += sRatioWidth가 영원히 전진하지 않는다. main.cc:97의 ROI 가드가 넓이만 보므로 4×30 같은 가늘고 긴 박스(넓이 120)면 이 경로에 들어갈 수 있다.

중대

후보 하나마다 calloc, 그리고 free는 레포 어디에도 없다

myHistogram은 호출될 때마다 calloc으로 새 배열을 할당해 반환한다. 이것을 매 후보 윈도마다 호출하는 것이 mySimilarity다. grep으로 확인한 결과 cv_tracker.ccmain.cc를 통틀어 free() 호출은 0건이고, 유일한 deletemain.cc:67에 주석 처리되어 있다.

imageProcessing/imageProcessing/cv_tracker.cc:142, :152 · 대응하는 free 없음
// :142  후보 윈도마다 한 번씩
	double * nHists = myHistogram(img, rc);
// :152  매번 새 할당, NULL 검사도 없음
	double * hists = (double*)calloc(param.hist_bins, sizeof(double)), hist_size = 256 / param.hist_bins;

프레임당 호출 횟수는 탐색 격자(:49–63, 약 50–80회) × 반복 횟수(측정값 3–6회)에 크기 탐색(:76–85, 약 25회)과 :99의 모델 갱신을 더한 값이다. 시뮬레이션에서 관측된 반복 수 기준으로 프레임당 200–500회, 회당 128바이트이면 프레임당 25–60KB가 새고 재생 시간에 비례해 선형으로 증가한다. 240프레임짜리 데모 하나가 수 MB 규모다.

initilizethis->objectHists(:23)도 소멸자 Tracker::~Tracker(void) {}(:8)가 비어 있어 해제되지 않는다.

경미

탐색창 클램프에서 rows와 cols가 뒤바뀌어 있다

폭을 hsv.rows(세로 픽셀 수)로, 높이를 hsv.cols(가로 픽셀 수)로 자른다. 가로 영상에 작은 박스라면 클램프가 애초에 걸리지 않아 드러나지 않지만, 세로 영상이나 큰 ROI에서는 틀린 경계가 된다. 같은 실수가 example/open.c:97, :110에도 있다.

			temp = Rect(max(nRect.x - (int)param.search_range, 0), max(nRect.y - (int)param.search_range, 0),
				min(nRect.width + (int)param.search_range * 2, hsv.rows), min(nRect.height + (int)param.search_range * 2, hsv.cols));
경미

역투영 인덱싱이 hist_bins == 16에서만 우연히 맞는다

myHistogram256 / hist_bins로 나눠 bin을 정하는데(:152, :157), myHistogramValuehist_bins로 나눈다(:201). 두 값이 같아지는 경우는 hist_bins = 16뿐이고, 기본값이 정확히 16이라 지금은 동작한다. 파라미터를 바꾸는 순간 배열 밖을 읽는다.

// :152  bin 폭 = 256 / hist_bins
double * hists = ..., hist_size = 256 / param.hist_bins;
// :201  나누는 수가 다르다
return hists[matrixAt(img, x, y) / param.hist_bins];
// 인덱스 상한 계산 결과
hist_bins= 8: myHistogram 최대 idx=  7 (배열 크기 8) | myHistogramValue 최대 idx= 31  → OUT OF BOUNDS
hist_bins=16: myHistogram 최대 idx= 15 (배열 크기 16)| myHistogramValue 최대 idx= 15  → ok
hist_bins=32: myHistogram 최대 idx= 31 (배열 크기 32)| myHistogramValue 최대 idx=  7  → ok

덧붙여 matrixAt의 경계 검사 x > img.size().width(:191)는 >=여야 한다. 폭과 같은 좌표가 통과해 한 픽셀 너머를 읽는다.

경미

모델 히스토그램 갱신 가드가 항상 열려 있다

:97if (param.search_range == SEARCH_MIN)은 "충분히 수렴했을 때만 모델을 갱신한다"는 의도의 가드다. 그런데 앞서 본 대로 :69의 계수가 1을 넘지 못해 탐색 반경은 첫 프레임에 하한 4.0에 도달한 뒤 계속 그 값이다(시뮬레이션 확인). 결과적으로 조건은 매 프레임 참이 되고, 모델은 현재 박스가 덮은 영역으로 무조건 0.05씩 끌려간다. 추적이 빗나가도 이를 막을 유사도 임계값은 없다. 전형적인 model drift 구조다.

잘한 것

matrixAt(:189–197)에서 img.data + y*img.step + x*img.elemSize()로 직접 포인터 산술을 하고 채널을 channel_ratio로 가중 합산한 부분은, cvGet2D를 쓰는 example/open.c:245 방식보다 명백히 빠르고 의도가 분명하다. L2 노름을 example/open.c:341처럼 int total에 누적해 잘라먹지 않고 double total(:159)로 받은 것도 cv_tracker.cc가 개선한 지점이다.

B — OpenCV 베이스라인과 프레임 루프

브리프의 질문 — "OpenCV의 meanShift/CamShift를 그냥 호출했는가" — 에 대한 답은 "호출했지만, 그것은 자작 구현의 비교 대상으로 세운 것"이다. 문제는 그 비교 대상이 작동하지 않는다는 데 있다.

치명적

Rect를 값으로 넘겨 OpenCV 추적 결과가 버려진다

OpenCV의 meanShift(InputArray, Rect& window, TermCriteria)는 두 번째 인자를 참조로 받아 갱신하는 것이 전부인 API다. 그런데 Tracker::cvMeanShiftRect를 값으로 선언했다. 갱신은 지역 복사본에서 끝나고 호출자의 cvRectinitilize(:28)에서 설정된 최초 ROI에 영원히 고정된다.

즉 베이스라인은 매 프레임 같은 자리에서 다시 출발해 근처 모드로 수렴할 뿐, 상태를 이어받지 않는다. 추적이 아니라 매 프레임 독립 재검출이고, 목표가 최초 박스를 벗어나면 따라가지 못한다.

imageProcessing/imageProcessing/cv_tracker.h:95–97 · 선언부터 값 전달
		// cv::meanshift
		void cvMeanShift(Mat&, const Mat&, Rect);
		// cv::camshift
		void cvCamShift(Mat&, const Mat&, Rect);
// :128  멤버 cvRect를 넘기지만 복사본이 만들어진다
			cvMeanShift(img, backproj, cvRect);

// :329  rc는 지역 복사본. meanShift가 갱신해도 호출자에게 돌아가지 않는다
void Tracker::cvMeanShift(Mat& img, const Mat& backproj, Rect rc)
{
	meanShift(backproj, rc, TermCriteria(CV_TERMCRIT_EPS | CV_TERMCRIT_ITER, param.max_itrs, 1));
	rectangle(img, rc, Scalar(0, 0, 255), 3, CV_AA);
}

이 결함은 레포에 커밋된 증거로 직접 확인된다. README.md:12가 자랑스럽게 링크한 TESTED/tested.gif(726×442, 240프레임)에서 40번째와 90번째 프레임을 뽑아 보면, 초록 박스(:116, 자작)는 대상 물체를 따라 화면 중앙을 가로질러 이동하는데 빨간 박스(:332, OpenCV)는 왼쪽 하단 같은 자리에 머문 채 미세하게 떨기만 한다. 저자가 성공 데모라고 올린 파일이 베이스라인이 죽어 있다는 증거가 되어 있다.

중대

유일한 추적 실패 복구 로직이 도달 불가능한 데다, 도달해도 무효다

프로젝트 전체에서 "대상을 잃었을 때"를 처리하는 코드는 cvCamShift 끝의 :343–347뿐이다. 박스가 1픽셀 이하로 쪼그라들면 탐색창을 다시 넓히는, 전형적인 CamShift 복구 패턴이다. 그런데 두 겹으로 죽어 있다.

첫째, 이 분기에 들어갈 방법이 없다. cvMeanshiftcv_tracker.h:82에서 true로 하드코딩된 protected 멤버이고 setter도, 이를 바꾸는 UI도, 명령행 옵션도 없다. :126의 분기는 항상 meanShift 쪽으로만 간다. 둘째, 설령 들어가도 rc가 값 복사본이라 재조정 결과가 함수 밖으로 나가지 못한다.

imageProcessing/imageProcessing/cv_tracker.h:81–82 · 및 cv_tracker.cc:343–347
		// if off, openCV method apply camshift;
		bool cvMeanshift = true;   // ← setter 없음. 아래 전부 도달 불가

	if (rc.width <= 1 || rc.height <= 1)
	{
		int cols = backproj.cols, rows = backproj.rows, r = (MIN(cols, rows) + 5) / 6;
		rc = Rect(rc.x - r, rc.y - r, rc.width + 2 * r, rc.height + 2 * r) & Rect(0, 0, cols, rows);
	}

자작 경로에도 실패 감지가 없다. mySimilarity의 최대값을 무조건 받아들일 뿐, "이 정도로 안 닮았으면 놓친 것"이라는 임계값이 없다. 앞서 본 무조건 모델 갱신과 합쳐지면 한 번 빗나갔을 때 되돌아올 경로가 존재하지 않는다.

중대

역방향 드래그가 ROI 가드를 통과해 예외로 죽는다

ROI는 x - sPoint.x, y - sPoint.y로 만들어지므로 오른쪽 아래→왼쪽 위로 끌면 width와 height가 둘 다 음수가 된다. 그리고 유효성 검사가 rect.area() > 100이다. 음수 × 음수는 양수이므로 (200,200)→(100,100) 드래그는 넓이 10000으로 가드를 통과한다.

그대로 tracker.initilize로 들어가면 cv_tracker.cc:264Mat target(hsv, rc);가 음수 크기 ROI에 대해 cv::Exception을 던진다. try/catch가 없으므로 프로그램이 종료된다. 사용자가 박스를 반대 방향으로 그렸다는 것이 유일한 조건이다.

				p->rect = { p->sPoint.x, p->sPoint.y, x - p->sPoint.x, y - p->sPoint.y };
				p->drag = false;
				
				if (p->rect.area() > 100)   // ← (-100)*(-100) = 10000 > 100. 통과한다
					p->updated = true;
경미

컬러 모델 메뉴가 자작 추적기에는 아무 영향이 없다

main.cc:60이 HSV / RGB / hue / gray 네 가지를 물어보지만, 자작 경로는 initilize(cv_tracker.cc:18)와 run(:39) 모두 무조건 CV_BGR2HSV로 변환하고, matrixAt(:195)은 channel_ratio {0.5, 0.25, 0.25}(cv_tracker.h:48)로 세 채널을 고정 비율 합산한다. 선택은 OpenCV 경로의 switch에만 닿는다. 사용자에게 네 개의 선택지를 주면서 실제로는 하나만 구현된 셈이다.

입력 검증도 없다. cin >> modelstatic_cast<COLOR_MODEL>(model - 1)(:65)에 5를 넣으면 범위 밖 enum이 되어 cvHistogramswitch가 아무 case도 타지 않고, model3d가 빈 채로 calcBackProject에 들어간다. 숫자가 아닌 입력이면 model이 0이 되어 -1이 된다.

경미

첫 프레임 검사 누락과 의도적으로 주석 처리된 delete

main.cc:120*vc >> frame; 직후 imshow를 호출하는데 frame.empty() 검사가 없다. 파일은 열리지만 첫 프레임 디코딩에 실패하는 경우(코덱 불일치 등) 그대로 예외다. 루프 안의 두 번째 읽기에는 :139에 검사가 있으므로 영상 종료 처리 자체는 되어 있다.

new VideoCapture(:50)에 대응하는 delete:66–67에 주석으로 남아 있다. 프로세스 종료로 회수되긴 하지만, 지웠다가 문제가 생겨 되돌린 흔적으로 읽힌다. cv_tracker.cc:94bRect = myRect = nRect;에서 bRect로의 대입도 지역 변수의 마지막 쓰기라 죽은 코드다.

C — 문서가 구현보다 앞서간 지점

docs/object_tracking.pptx 18장을 텍스트 추출해 전부 읽었다. 발표자는 "배지운 이영수" 두 명으로 적혀 있고, 목차는 1. Set target object → 2. Back projection → 3. Mean shift process → 4. Updating → 5. Other functions → References로 이어진다. 구조 자체는 성실하고, 마지막 장이 "한양대학교 확률및통계 강의자료"를 출처로 밝힌 점도 정직하다.

중대

13장의 "픽셀을 역투영 가중치로 mean shift 클러스터링"은 구현되지 않았다

슬라이드 13의 문구는 "Mean shift clustering of pixels with back projected weights"다. 이것이 강의자료가 가르치는 정석이다 — 각 픽셀에 역투영 값을 가중치로 주고, 그 가중치로 중심을 옮긴다.

실제 cv_tracker.cc:49–63은 다르다. 성긴 격자 위의 후보 윈도들을 순회하며, 각 윈도 전체의 히스토그램을 새로 계산해 모델과의 유사도를 구하고, 그 값을 가중치로 윈도 좌표들의 가중평균을 낸다. 픽셀 단위 가중이 아니라 윈도 단위 가중이고, 커널 가중 밀도의 모드를 오르는 것이 아니라 샘플링된 후보 집합의 가중 중심을 구하는 것이다. 동작은 비슷해 보일 수 있으나 mean shift의 수렴 보장(밀도 상승 방향으로의 이동)이 성립하는 구조가 아니다.

:106–112에 진짜 픽셀 단위 역투영이 있기는 하다. 다만 그것은 MY_histogram 창에 보여주기만 할 뿐, 위치 갱신에 쓰이지 않는다.

imageProcessing/imageProcessing/cv_tracker.cc:52–55 · 픽셀이 아니라 윈도에 가중치를 준다
					w = mySimilarity(hsv, Rect(temp.x + (temp.width / 2) + i, temp.y + (temp.height / 2) + j, nRect.width, nRect.height), this->objectHists);
					tW += w;
					nX += w * (temp.x + (temp.width / 2) + i);
					nY += w * (temp.y + (temp.height / 2) + j);
경미

OpenCV 예제 헤더를 커밋해 두고 파생 클래스에는 본인만 표기했다

example/tracker_opencv/code/tracker_opencv.h"Written by darkpgmr (http://darkpgmr.tistory.com), 2013"이라고 명시된 제3자 코드다. 그리고 cv_tracker.h의 OpenCV 경로 설계는 이 헤더와 상당히 겹친다 — init/run 형태의 공개 API, hist_bins·max_itrs·color_model을 담은 파라미터 구조체, CM_GRAY/CM_HUE/CM_RGB/CM_HSV enum, m_model/m_model3d에 대응하는 model/model3d 보호 멤버까지.

참고 자료를 레포에 함께 남긴 것 자체는 오히려 투명한 행동이다. 다만 cv_tracker.h:3–8main.cc:1–6의 파일 헤더는 저자 본인의 GitHub 주소만 적고 있고, 발표자료 References 장에도 OpenCV 공식 문서와 강의자료만 있을 뿐 이 출처는 없다. 파생한 쪽에 한 줄 표기를 남기는 것이 맞다.

경미

최상위 README가 구현에 대해 사실상 아무것도 말하지 않는다

README.md는 13줄이고 그중 실질 내용은 "using OpenCV to test image processing[object tracking]" 한 줄과 데모 GIF 링크뿐이다. 빌드 방법, OpenCV 버전, 필요한 Visual Studio 구성, 조작법(마우스로 ROI를 끌고 ESC로 종료), 알고리즘 요약이 전부 없다. 정작 그 정보의 일부는 소스 파일 상단 주석(cv_tracker.h:10–18)에만 들어 있다. 문서가 코드보다 앞서가지 않는 대신, 아예 따라오지 않은 경우다.

잘한 것

발표자료 9장이 자기 유사도를 "Modification version of Bhattacharyya distance"라고 스스로 밝혀 적었다. 정석과 다르다는 것을 알고 있었고 그것을 숨기지 않았다는 뜻이다. 과제 문서에서 이 정도의 자기 고지는 드물고, 평가자 입장에서는 "몰랐다"와 "알고 바꿨다"의 차이가 크다. 다만 하필 그 변형이 확률분포 성질을 없애는 방향이었다는 것이 앞의 치명적 지적이다.

D — 레포 위생과 이식성

치명적

646줄짜리 과제를 위해 699MB를 커밋했다

작업 트리 699MB, .git 160MB다. 원인은 TESTED/의 미디어가 아니다 — 그쪽은 mp4 두 개 약 11MB와 gif 4MB로 전체의 2%에 불과하다. 압도적인 주범은 OpenCV 3.0 SDK 전체를 x86/x64 × vc11/vc12 네 조합으로 통째 커밋한 것이다.

// git 히스토리의 최대 blob (바이트)
59,990,716  imageProcessing/example/VTD_data_videos/animal.avi
53,742,592  imageProcessing/imageProcessing/lib/x64/vc11/bin/opencv_world300d.dll
52,001,280  imageProcessing/imageProcessing/lib/x64/vc12/bin/opencv_world300d.dll
41,171,456  imageProcessing/imageProcessing/lib/x86/vc11/bin/opencv_world300d.dll
40,094,208  imageProcessing/imageProcessing/lib/x86/vc12/bin/opencv_world300d.dll
33,503,744  imageProcessing/imageProcessing/lib/x64/vc11/bin/opencv_world300.dll
   ... opencv_world300*.dll 8개 합계 약 310MB, opencv_ts300*.lib 8개 합계 약 99MB
 7,383,466  imageProcessing/Debug/test.jpg   // f93ca0a에서 커밋, 15b985e에서 삭제 — 히스토리에는 영구히 남음

덧붙일 점 셋. 첫째, 실제로 빌드에 쓰인 구성은 Debug|Win32 하나(imageProcessing.vcxproj:92,97, opencv_world300d.lib)이므로 나머지 세 조합의 라이브러리는 한 번도 쓰이지 않았다. 둘째, example/.gitignoreVTD_data_videos/, tracker_opencv/ 등을 나열하지만 그 파일들은 이미 커밋된 뒤라(901de66, 295122a) 아무 효과가 없다. 셋째, 당시 Visual Studio 프로젝트에서 SDK를 동봉하는 것이 드문 관행은 아니었으나, 그렇다고 60MB짜리 샘플 영상 animal.avi까지 함께 넣을 이유는 없었다.

중대

제3자 바이너리를 재배포하면서 라이선스 표기가 없다

find로 확인한 결과 레포 전체에 LICENSE, COPYING, NOTICE 류 파일이 하나도 없다. OpenCV는 3-clause BSD이고, 그 조항은 바이너리 형태로 재배포할 때 저작권 고지와 조건문을 동봉할 것을 요구한다. 지금 레포에서 BSD 전문이 존재하는 곳은 벤더링된 헤더 파일 내부 주석뿐이며(include/opencv/cv.h 등), lib/ 아래 DLL·LIB 8쌍에 대응하는 고지는 없다. 저자 자신의 코드에 대한 라이선스도 명시되지 않아 재사용 조건이 불분명하다.

경미

Windows 전용 구속과 빌드조차 되지 않는 x64 구성

macOS에서는 빌드가 불가능하며 그 사실은 명확히 기록해 둔다 — main.cc:20<Windows.h>를 포함하고, 파일 선택 대화상자가 OPENFILENAME::GetOpenFileName(main.cc:165–180)으로 구현되어 있으며, cv_tracker.h:23–26의 include 경로가 역슬래시("opencv2\core\core.hpp")다. C++ 표준에서 역슬래시가 든 헤더명은 구현 정의 동작이고 POSIX 툴체인에서는 그대로 깨진다. 프로젝트도 PlatformToolset v140, WindowsTargetPlatformVersion 8.1에 묶여 있다. 따라서 아래 모든 판정은 빌드가 아니라 정독과 산술 포팅 시뮬레이션에 근거한다.

그와 별개로 Debug|x64 구성은 Windows에서도 빌드되지 않았을 것이다. include 경로가 드라이브 루트 기준 \include이고 라이브러리 경로가 \lib\x86\vc12\lib, 즉 x64 구성이 x86 라이브러리를 루트 절대경로로 가리킨다. 다른 세 구성이 쓰는 프로젝트 상대경로 .\include와 대조된다.

  <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
      <AdditionalIncludeDirectories>\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
      <AdditionalLibraryDirectories>\lib\x86\vc12\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>

반복되는 패턴

  1. 기본값에서만 우연히 맞는 코드. myHistogramValue의 인덱스는 hist_bins가 정확히 16일 때만 맞고(:201 vs :152), 화면에 그려지는 박스는 자동조정 단계가 mean shift 결과를 덮어써 준 덕분에만 제자리에 있다(:83). 우연한 정합에 기대는 코드는 파라미터 하나를 바꾸는 순간 무너진다.
  2. 추가 기능이 본 알고리즘을 가린다. 헤더 주석(cv_tracker.h:6–8)이 내건 세 가지 부가기능 중 "dynamic search range"는 탐색 반경이 단조 감소해 첫 프레임에 하한에 붙어 무력화되고, "model histogram update"는 그 결과 가드가 항상 열려 무조건 갱신이 되며, "autosizing"은 위치 결정권까지 가져가 버렸다. 셋 다 본체를 다듬은 것이 아니라 본체 위에 덧댄 결과 본체를 대체했다.
  3. 참조로 넘겨야 할 것을 값으로 넘긴다. cvMeanShiftcvCamShiftRect(cv_tracker.h:95–97)가 그렇고, 그 하나로 베이스라인 추적과 실패 복구가 동시에 죽었다. C++ 값 의미론에 대한 이해가 아직 안 잡혀 있던 시점의 코드로 읽힌다.
  4. 할당은 하고 해제는 하지 않는다. myHistogramcalloc은 대응하는 free가 레포에 0건, new VideoCapturedelete는 주석 처리(main.cc:67), 소멸자는 비어 있다(cv_tracker.cc:8). 데모 길이가 짧아 드러나지 않았을 뿐이다.
  5. 사용자 입력을 신뢰한다. 컬러 모델 번호는 범위 검사 없이 enum으로 캐스팅되고(main.cc:61,65), ROI는 area() > 100이라는 부호를 놓친 가드 하나만 통과하면 된다(main.cc:97). 둘 다 예외로 직행한다.

지금 손본다면

  1. cv_tracker.h:95–97RectRect&로 바꾼다. 문자 두 개. 이것만으로 OpenCV 베이스라인이 실제 추적기가 되고 비교 실험이 성립하며, CamShift 복구 로직도 살아난다. 투자 대비 효과가 이 레포에서 가장 크다.
  2. 좌표 규약을 하나로 통일한다. :52를 중심 기준으로 고칠지 :71- width/2를 뺄지 정하고, :79를 그에 맞춘다. 그러면 mean shift 루프의 출력이 실제로 쓰이게 되고, 정지 화면에서 박스가 흘러가는 현상이 사라진다.
  3. 정규화를 L1로, 유사도를 sqrt로 바꾼다. :161total += hists[i]로, :145similarity += sqrt(hists[i] * nHists[i])로. 각각 한 줄이며, 그 순간 유사도가 Bhattacharyya 계수가 되어 값이 [0,1]에 갇힌다. 부수 효과로 :69twRatio 스케일이 예측 가능해지고 "이 값 이하면 놓친 것"이라는 실패 임계값을 정의할 수 있게 된다. 과목 관점에서 가장 중요한 수정이다.
  4. 자작 루프에 max_itrs를 건다. 이미 선언되어 있고(cv_tracker.h:56) OpenCV 경로에서만 쓰이고 있으니, :42do에 반복 카운터를 붙이면 끝이다. 수렴 임계값은 루프 밖에서 고정한 별도 상수를 쓴다.
  5. myHistogramstd::vector<double> 반환으로 바꾼다. calloc/누락된 free 문제가 통째로 사라지고 호출부는 그대로다. 여유가 있으면 후보마다 전체 히스토그램을 다시 세는 대신 적분 히스토그램을 쓰면 프레임당 수십만 번의 픽셀 접근이 줄어든다.
  6. ROI를 Rect(sPoint, Point(x,y))로 만든다. OpenCV의 두 점 생성자가 좌표를 정규화하므로 음수 크기 자체가 나오지 않는다. main.cc:97의 넓이 가드는 그대로 둬도 된다.
  7. git filter-repolib/·example/VTD_data_videos/·Debug/를 히스토리에서 들어내고 LICENSE를 추가한다. 699MB가 1MB 아래로 떨어진다. OpenCV는 NuGet이나 CMake find_package로 받게 하고, 파생한 cv_tracker.h에 darkpgmr 출처 한 줄을 남긴다.