깊이가 없는 카메라로 위치를 구한다는 것
카메라 한 대로 검출한 객체의 실제 좌표를 구해야 하는 상황이 있다. 스테레오도 라이다도 없고, 자세(pose)와 내부 파라미터만 있다.
이때 성립하는 것과 성립하지 않는 것을 먼저 분명히 해야 한다. 단안 카메라에는 깊이가 없다. 픽셀 하나가 결정하는 것은 3차원 공간의 점이 아니라 카메라 중심에서 뻗어나가는 광선 하나다. 40m 앞의 작은 물체와 400m 앞의 큰 물체가 정확히 같은 픽셀에 맺힌다.
그래서 좌표를 얻으려면 가정이 하나 더 필요하다. 보통은 “객체가 지면 위에 있다”는 가정을 쓴다. 광선을 지면 평면과 교차시키면 그 교점이 답이 된다.
공선조건에서 지면 교차까지
카메라 중심, 이미지 위의 점, 실제 물점이 한 직선 위에 있다는 것이 공선조건이다. 코드로 옮기면 다섯 단계다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def unproject(u, v, K, dist, R, t, ground_z):
# 1) 렌즈 왜곡 보정
pts = cv2.undistortPoints(np.array([[[u, v]]], np.float32), K, dist)
x_n, y_n = pts[0, 0] # 이미 정규화 좌표로 나온다
# 2) 카메라 좌표계 광선
ray_cam = np.array([x_n, y_n, 1.0])
# 3) 카메라 중심 (월드)
C = -R.T @ t
# 4) 월드 좌표계 광선
ray = R.T @ ray_cam
ray = ray / np.linalg.norm(ray)
# 5) 지면 평면과 교차
s = (ground_z - C[2]) / ray[2]
return C + s * ray
1~4단계가 공선조건이고, 5단계가 깊이를 정하는 가정이다. 이 구조를 이해하고 나면 오차가 어디서 들어오는지도 같이 보인다.
지면 고도 오차가 수평 오차로 바뀌는 비율
5단계에서 쓰는 ground_z가 틀리면 결과는 광선 위에서 앞뒤로 밀린다. 얼마나 밀리는지는 카메라의 하향각 θ로 결정된다.
1
수평 오차 ≈ 고도 오차 / tan(θ)
| 하향각 θ | 계수 | 고도 1m 오차 → 수평 오차 |
|---|---|---|
| 60° | 0.58 | 0.6m |
| 40° | 1.19 | 1.2m |
| 20° | 2.75 | 2.7m |
| 10° | 5.67 | 5.7m |
| 5° | 11.4 | 11.4m |
θ가 작아질수록 계수가 폭발한다. 카메라가 수평에 가까울수록 광선이 지면과 얕은 각으로 만나고, 평면이 조금만 위아래로 움직여도 교점이 크게 미끄러진다.
그래서 하향각에 하한 게이트를 두는 것이 실질적으로 필요하다. θ가 5도 미만이면 좌표를 내놓지 않고 버리는 편이, 11배로 증폭된 값을 하류로 흘려보내는 것보다 낫다. 지평선 근처 픽셀은 아예 계산이 발산한다(ray[2] → 0).
bbox의 어느 점을 쓰는가
검출 결과는 바운딩 박스다. 여기서 한 점을 골라야 하는데, 지면 가정을 쓰기로 했으면 하단 중앙(foot point)을 써야 한다.
1
2
def extract_center_from_bbox(xmin, ymin, xmax, ymax):
return ((xmin + xmax) / 2.0, ymax)
bbox 중심을 쓰면 그 점은 객체의 허리쯤에 해당하므로 지면 위에 있지 않다. 카메라 각도에 따라 1~3m의 편향이 그대로 붙는다. 그런데 이 편향은 일정한 방향이라 정답값과 비교하기 전까지는 잘 안 드러난다. 좌표가 그럴듯하게 나오고 예외도 안 나기 때문이다.
다만 foot point도 완전하지 않다. 그것은 카메라에서 본 앞쪽 접지선이지 객체의 중심이 아니다. 정답값이 객체 원점(중심) 기준이면 전장 10m짜리 대상에서 3~5m의 공통 편향이 남는다. 이 차이는 보정으로 지울 수 있지만, 그러려면 먼저 정답이 어느 점을 가리키는지 정의부터 확인해야 한다. 남은 편향을 알고리즘 문제로 오해하고 몇 주를 쓰는 일이 여기서 흔하게 생긴다.
좌표계는 하나만 쓴다
입력 GPS는 WGS84(경위도)로 오고 출력도 WGS84로 나가야 하는데, 내부 계산을 경위도로 하면 안 된다. 경위도는 각도라 거리 계산이 성립하지 않는다. 위도 37도에서 경도 1도는 위도 1도보다 20% 짧다.
내부는 미터 단위 투영 좌표계로 통일한다.
1
2
3
4
from pyproj import Transformer
wgs84_to_utm = Transformer.from_crs("EPSG:4326", "EPSG:5179", always_xy=True)
utm_to_wgs84 = Transformer.from_crs("EPSG:5179", "EPSG:4326", always_xy=True)
always_xy=True를 빼면 축 순서가 CRS 정의를 따르는데, EPSG:4326은 정의상 (위도, 경도) 순이다. 대부분의 코드는 (경도, 위도)를 기대하므로 이 플래그를 빼는 순간 좌표가 통째로 뒤바뀐다. 그리고 두 값이 모두 30~130 범위라 결과가 그럴듯해 보인다.
Transformer 생성은 무겁다(수 밀리초). 프레임마다 만들면 그 자체가 병목이 되므로 모듈 전역에서 한 번만 만들어 재사용한다.
자세 규약을 실측으로 확정한다
이 계산에서 가장 많은 시간을 쓴 곳은 수식이 아니라 각도의 부호와 기준이었다. 짐벌 피치가 절대 하향각인지 수평 기준 상대각인지, 회전 행렬이 월드→카메라인지 카메라→월드인지, 요가 북쪽 기준 시계방향인지 반시계인지.
문서에 적힌 것과 실제 장비가 내보내는 값이 다른 경우가 드물지 않다. 그래서 규약을 정할 때는 가설을 세우고 알려진 정답 좌표를 역산해서 재현되는지로 확정했다. 두 가설 중 하나만 실제 관측을 재현한다.
이 검증을 건너뛰면 어떻게 되냐면, 회전이 90도 틀어진 채로도 시스템이 정상 동작하는 것처럼 보인다. 오류가 안 나고, 좌표도 지도 위 어딘가에 찍힌다. 정답값과 대조하기 전까지는 아무도 모른다.
정리
- 단안에서 좌표를 얻으려면 반드시 평면 가정이 추가된다. 그 가정이 오차의 지배적 요인이다
- 고도 오차는
1/tan(θ)배로 수평 오차가 된다. 하향각 게이트가 필요하다 - 지면 가정을 쓰면 bbox 하단 중앙이 맞다. 다만 정답의 기준점 정의를 먼저 확인한다
- 내부 계산은 미터 단위 투영 좌표계로 통일한다
- 각도·회전 규약은 문서가 아니라 실측 역산으로 확정한다
이 계열의 버그는 예외를 던지지 않는다. 조용히 그럴듯한 값을 낸다. 그래서 정답값과 대조하는 절차를 파이프라인에 상시로 붙여두지 않으면 틀린 채로 몇 달을 간다.