치명적히스토그램이 확률분포가 아니고, 유사도가 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는 정석 형태를 그대로 갖고 있다.
// 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];
for (int i = 0; i < 16; i++)
{
w += sqrt(histogram[i] * next_frame_histogram[i]);
}
더불어 커널 프로파일이 없다. 코드 전체에 Epanechnikov도 Gaussian도, 중심에서 멀어질수록 가중치를 줄이는 어떤 공간 가중도 존재하지 않는다. Comaniciu & Meer의 mean shift 벡터가 커널 미분에서 유도된다는 점을 생각하면, 이것은 구현 세부의 누락이 아니라 방법의 토대가 빠진 것이다.
치명적좌표 규약이 두 곳에서 엇갈려, mean shift 루프의 결과가 버려진다
#
후보 윈도를 만드는 :52는 Rect(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)를 뺀 값이다. 둘째, 화면에 그려지는 박스가 그래도 객체 위에 있는 이유는 :83의 nRect = 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이 된다. 그러면 :49의 i += sRatioWidth가 영원히 전진하지 않는다. main.cc:97의 ROI 가드가 넓이만 보므로 4×30 같은 가늘고 긴 박스(넓이 120)면 이 경로에 들어갈 수 있다.
중대후보 하나마다 calloc, 그리고 free는 레포 어디에도 없다
#
myHistogram은 호출될 때마다 calloc으로 새 배열을 할당해 반환한다. 이것을 매 후보 윈도마다 호출하는 것이 mySimilarity다. grep으로 확인한 결과 cv_tracker.cc와 main.cc를 통틀어 free() 호출은 0건이고, 유일한 delete는 main.cc:67에 주석 처리되어 있다.
// :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 규모다.
initilize의 this->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에서만 우연히 맞는다
#
myHistogram은 256 / hist_bins로 나눠 bin을 정하는데(:152, :157), myHistogramValue는 hist_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)는 >=여야 한다. 폭과 같은 좌표가 통과해 한 픽셀 너머를 읽는다.