지난 주는 고민으로 가득한 시도뿐이라 기록할 것이 별로 없었습니다. 그래도 21주차에는 눈에 보이는 성과가 있어서 너무나도 도파민이 뿜뿜했던 기억이 있습니다.
그러면 바로 구현 내용으로 들어가보도록 하겠습니다.
카메라 캘리브레이션부터 RBF 매핑, Z=1200mm 평면 설계, DeepSORT 트래킹까지
💡 핵심 흐름
RFID 태깅 감지 → 해당 인물의 신체 좌표 추출 → 레이저로 1200mm 높이 조준 → 관리자 확인
| 구성 요소 | 모델/사양 | 역할 |
|---|---|---|
| IP 카메라 | ONVIF 지원 (1920×1080) | RTSP 영상 + 메타데이터 |
| RFID 리더기 | TCP 소켓 연결 | 태깅 감지 → person_id 전달 |
| PC | Intel Xe 내장 GPU | 영상처리, RBF 연산, 관리자 UI |
| ESP8266 | WiFi 모듈 | UDP 수신 → UART → STM32 |
| STM32 | ARM Cortex-M4 | PWM 생성 → 팬틸트 서보 제어 |
| 서보 모터 | HDS-2288 × 2 | 팬(좌우) + 틸트(상하) 2축 |
| 레이저 | 포인터 모듈 | 1200mm 평면 조준 |
RFID 태깅
→ TCP 수신 → person_id 확정
→ RFID 리더기 위치 픽셀(rfid_v) 기준 bbox ratio 계산
매 프레임
→ ONVIF bbox 수신 → 칼만 필터 노이즈 제거
→ target_u = bbox_cx
→ target_v = bbox_top + bbox_height × ratio
→ RBF eval(u, v) → pan_pwm, tilt_pwm
→ UDP → ESP8266 → UART → STM32 → 서보 구동

레이저가 실제로 가리켜야 할 3D 좌표를 구하려면 깊이(Z) 정보가 필요하다. 그런데 단안 카메라만으로는 깊이를 직접 측정할 수 없다. 처음에는 다음과 같은 방법들을 검토했다.
| 방법 | 문제점 |
|---|---|
| 사람 키 + 픽셀 비율로 추정 | 키가 다르고, 가려지면 bbox가 잘려 비율이 틀어짐 |
| 바닥면(Z=0) 교차 | 카메라 틸트각 정밀 측정 필요, 교실 환경 내에서 책상이나 의자에 가려지면 추정 실패 |
| 스테레오 카메라 추가 | 하드웨어 추가 비용 및 설치 복잡도 |
| Z=1200mm 고정 평면 | 키/거리 무관, 깊이 추정 불필요 ✅ |
💡 사람의 최소 키를 120cm로 가정하면, 1200mm 높이는 어떤 사람이든 몸통에 해당한다. 이 평면을 고정 타겟으로 삼으면 거리/키에 관계없이 일관된 조준이 가능하다.
카메라가 천장 벽면 끝에 비스듬히 설치되어 있기 때문에, 실세계의 수평 평면(Z=1200mm)은 카메라 영상에서 직선이 아닌 곡선으로 보인다. RBF 캘리브레이션을 통해 이 평면이 영상에서 어떤 픽셀 위치에 해당하는지 역산할 수 있다.

1200mm 평면의 영상 픽셀을 구하더라도, 사람의 bbox에서 그 위치가 상단 몇 %인지는 사람마다, 거리마다 다르다. 이를 해결하기 위해 RFID 태깅 순간을 활용했다.
# RFID 리더기 위치 = 실세계 정확히 Z=1200mm
# 태깅 순간 그 픽셀의 v 좌표 = rfid_v (사전 측정, 고정값)
# 태깅 순간 bbox에서의 비율 계산
ratio = (rfid_v - bbox_top) / bbox_height
# 이후 추적에서 동일 비율 적용
target_v = bbox_top + bbox_height * ratio
✅ 장점: 키가 달라도, 거리가 달라도 태깅 순간의 실측값으로 ratio가 결정되므로 추정 오차가 없다. RFID 리더기가 Re-ID보다 훨씬 정확한 '높이 캘리브레이션 도구'가 된다.
카메라 내부 파라미터(fx, fy, cx, cy)는 체커보드 캘리브레이션으로 사전에 구했다. 카메라 틸트각, 레이저 오프셋 등 나머지 미지수는 모두 현장 측정으로 한번에 흡수했다.
총 16개 막대를 바닥의 격자 위치에 세우고, 막대 1200mm 높이에 스티커를 붙인 뒤 다음 두 가지를 기록했다.
| 포인트 | X(cm) | Y(cm) | u(px) | v(px) | pan PWM | tilt PWM |
|---|---|---|---|---|---|---|
| p0 | -370 | 65 | 80 | 405 | 890 | 1320 |
| p7 | 0 | 221 | 1607 | 550 | 1605 | 1235 |
| p10 | -234 | 0 | 5 | 632 | 870 | 1170 |
| p15 | 0 | 100 | 1535 | 898 | 1570 | 1065 |
| ... | ... | ... | ... | ... | ... | ... |
| (총 16개) |

16개 캘리브 포인트를 이용해 두 가지 RBF 보간 함수를 학습했다.
pixel(u, v) → pan PWMpixel(u, v) → tilt PWMRBF(Radial Basis Function)는 알고 있는 점들로부터의 거리를 기반으로 모르는 점을 추정하는 방법이다. Thin-Plate Spline 커널 φ(r) = r²·log(r) 을 사용하면 2D 픽셀 공간에서 가장 부드럽고 정확한 보간이 가능하다.
# 학습 (1회)
from scipy.interpolate import RBFInterpolator
rbf_pan = RBFInterpolator(pixels, pwms[:,0],
kernel='thin_plate_spline')
rbf_tilt = RBFInterpolator(pixels, pwms[:,1],
kernel='thin_plate_spline')
# 추론 (매 프레임)
pan = int(rbf_pan([[u, v]])[0])
tilt = int(rbf_tilt([[u, v]])[0])
카메라 파라미터, 서보 스펙, 레이저 오프셋을 아무것도 몰라도 된다. 그냥 "이 픽셀이면 이 PWM이더라"는 실측 데이터 16개만 있으면 끝이다.

Leave-One-Out 교차검증으로 16개 포인트 각각을 제외하고 나머지 15개로 보간한 뒤 예측 오차를 측정했다.
| 항목 | pan 오차 | tilt 오차 |
|---|---|---|
| 평균 오차 | 6.1 PWM | 7.4 PWM |
| 최대 오차 | 23.0 PWM | 34.0 PWM |
| 각도 환산 | ≈ 0.6° | ≈ 0.7° |
| 실용 판정 | ✅ 충분 | ✅ 충분 |
📌 p15 포인트(X=0, Y=100) 추가 후 기존 p7의 tilt 오차가 146 PWM → 9 PWM으로 94% 감소했다. 데이터 분포가 고른 것이 정확도에 결정적 영향을 준다.
세계좌표(X, Y) → 픽셀(u, v) 역방향 RBF도 함께 학습하여, 실세계 격자가 카메라 영상에서 어떻게 보이는지 실시간으로 오버레이했다.
# 실세계 격자 → 영상 투영
for X in range(-500, 10, 100): # X 등간격 (cm)
for Y in range(0, 1050, 100): # Y 등간격 (cm)
u = rbf_u.eval(X, Y) # 세계→픽셀 RBF
v = rbf_v.eval(X, Y)
cv2.circle(frame, (int(u), int(v)), 3, (80, 220, 80), -1)
투영 결과, 실세계에서 수평인 격자가 영상에서는 카메라 시점에 따라 곡선으로 휘어져 보였다. 카메라가 비스듬히 설치되어 있기 때문에 당연한 현상이며, RBF가 이 왜곡을 자연스럽게 학습했음을 확인했다.

카메라 ONVIF 메타데이터로 받는 bbox는 카메라 내부 AI가 매 프레임 독립적으로 계산하기 때문에, 사람이 가만히 있어도 bbox가 흔들린다. 실측 결과는 충격적이었다.
| 구분 | 측정값 | 영향 |
|---|---|---|
| pan 노이즈 | 30 PWM | 레이저 좌우 떨림 |
| tilt 노이즈 | 52 PWM | 레이저 상하 떨림 (더 심각) |
| bbox 픽셀 오차 | 약 104px | tilt 방향 RBF 민감도가 높음 |
bbox 노이즈 문제를 해결하기 위해 OpenCV의 CSRT, KCF 트래커를 시도했다. 초기화 이후 bbox 노이즈가 크게 줄어드는 장점이 있었지만 치명적인 문제들이 있었다.
⚠️ Visual Tracker는 단기 추적에는 효과적이지만, 사람이 겹치거나 일시적으로 가려지는 실제 환경에서는 신뢰할 수 없다는 결론에 도달했다.

현업에서 가장 널리 쓰이는 DeepSORT(Detection + 칼만 필터 + Re-ID) 를 도입하기로 결정했다. 우리 시스템의 특성상 전체 DeepSORT를 구현할 필요는 없다.
DeepSORT 에 대해 잘 설명된 블로그를 첨부하며 DeepSORT에 대한 설명을 생략하도록 하겠다.
| DeepSORT 구성요소 | 필요 여부 | 이유 |
|---|---|---|
| Detection (ONVIF) | ✅ 이미 있음 | 카메라 AI가 처리 |
| 칼만 필터 | ✅ 필요 | bbox 노이즈 흡수 |
| 헝가리안 매칭 | ✅ 필요 | 겹칠 때 올바른 ID 유지 |
| CNN Re-ID | △ 선택 | 색상 히스토그램으로 대체 가능 |
| 다중 트랙 관리 | △ 단순화 | RFID가 타겟 ID 확정 |
RFID가 이미 타겟 ID를 100% 정확하게 확정해주기 때문에, DeepSORT의 Re-ID 역할 대부분을 RFID가 대체한다. 결과적으로 칼만 필터 + 색상 기반 Re-ID + 헝가리안 매칭의 경량 구현으로 충분하다.
현재 bbox 중앙을 타겟으로 사용하면 팔을 벌리거나 돌아서는 경우 bbox 중심이 실제 몸 중심과 달라지는 문제가 있다. MediaPipe Pose를 도입하여 어깨 keypoint를 직접 추출할 예정이다.
# MediaPipe 어깨 중심점 추출
mp_pose = mp.solutions.pose
pose = mp_pose.Pose(min_detection_confidence=0.5)
result = pose.process(roi_rgb)
lm = result.pose_landmarks.landmark
L_SH = lm[mp_pose.PoseLandmark.LEFT_SHOULDER]
R_SH = lm[mp_pose.PoseLandmark.RIGHT_SHOULDER]
# 가시성 기반 fallback 전략
if L_SH.visibility > 0.5 and R_SH.visibility > 0.5:
# 1순위: 양쪽 어깨 중점
u = (L_SH.x + R_SH.x) / 2 * img_w
v = (L_SH.y + R_SH.y) / 2 * img_h
elif L_SH.visibility > 0.5:
# 2순위: 한쪽 어깨
u, v = L_SH.x * img_w, L_SH.y * img_h
else:
# 3순위: bbox 상단 26% fallback
u = (x1 + x2) / 2
v = y1 + (y2 - y1) * 0.26
어깨 keypoint는 팔을 벌려도 변하지 않고, bbox 가로 크기 변화에 영향받지 않는다. 또한 RFID 태깅 순간 어깨 픽셀 위치와 1200mm 기준점의 오프셋 비율을 계산해두면, 이후 어깨가 가려져도 안정적으로 타겟을 유지할 수 있다.
경량 DeepSORT를 구현하여 사람이 겹치거나 일시적으로 화면을 벗어나는 상황에서도 ID를 안정적으로 유지할 계획이다.
🔧 실용적 접근:
deep-sort-realtimePython 라이브러리로 먼저 검증한 뒤, 검증이 완료되면 C++로 이식한다. ONVIF Detection이 이미 구현되어 있으므로 Re-ID + 칼만 + 헝가리안만 추가하면 된다.
현재 PC에서 수행하는 RBF 연산(pixel → PWM)을 STM32로 이식하면, PC로부터 픽셀 좌표(u, v)만 받아도 서보를 직접 제어할 수 있다. 통신 부하가 줄어들고, PC 연결이 끊겨도 마지막 픽셀 기준으로 자체 동작이 가능해진다.
| 항목 | 값 | 비고 |
|---|---|---|
| RBF eval 연산량 | ~262회 부동소수점 | pan + tilt 각 1회 |
| 예상 실행 시간 | ~13μs | STM32F4 @ 168MHz |
| 30fps 예산 | 33,333μs | 여유 2,500배 |
| 메모리 사용량 | ~432 bytes | SRAM 192KB 중 0.2% |
| 필요 구현 | eval() 함수만 | fit()은 PC에서 1회 계산 |
// STM32 RBF eval (C)
// 학습된 가중치는 PC에서 추출 후 const 배열로 저장
float rbf_eval(float u, float v,
const float* W,
float a0, float a1, float a2) {
float s = a0 + a1*u + a2*v;
for (int i = 0; i < 16; i++) {
float du = u - CALIB_U[i];
float dv = v - CALIB_V[i];
float r = sqrtf(du*du + dv*dv);
s += W[i] * r * r * logf(r + 1e-6f);
}
return s;
}
// 사용: UART로 (u, v) 수신 후
float pan = rbf_eval(u, v, PAN_W, PAN_A0, PAN_A1, PAN_A2);
float tilt = rbf_eval(u, v, TILT_W, TILT_A0, TILT_A1, TILT_A2);
servo_set_pwm((int)pan, (int)tilt);
이 프로젝트에서 가장 어려웠던 부분은 "레이저가 실제로 어디를 가리켜야 하는가" 라는 좌표 설계 문제였다. 단안 카메라만으로 3D 좌표를 정확히 추정하는 것은 본질적으로 어렵다.
Z=1200mm 고정 평면 + RFID 태깅 기반 ratio 초기화 라는 조합은 이 문제를 하드웨어 제약을 활용해 우아하게 해결한 방법이었다. 카메라를 추가하거나 복잡한 알고리즘을 쓰지 않아도, 이미 존재하는 RFID 인프라가 3D 좌표 캘리브레이션 도구가 되었습니다.
앞으로 MediaPipe 어깨 탐지와 DeepSORT, STM32 포팅까지 완료되면, 하드웨어부터 임베디드까지 전 스택이 완성된 실시간 레이저 트래킹 시스템이 구축될 것 같습니다 .
두구두구두구 ///!!
Tags: Computer Vision Calibration RBF DeepSORT STM32 레이저 트래킹 RFID
실시간 레이저 트래킹 시스템 정말 보고 싶네요