왜 캘리브레이션이 필요한가
카메라는 3D 세계를 2D 이미지로 투영하는 장치입니다. 이 과정에서 두 가지 문제가 생깁니다.
- 렌즈 왜곡: 실제 직선이 이미지에서 휘어 보임 (배럴/핀쿠션 왜곡)
- 투영 관계의 불확실성: 픽셀 좌표와 실제 3D 좌표 사이의 수치 관계를 모름
캘리브레이션은 이 두 가지를 수치로 정확히 구하는 과정입니다.
두 카메라를 함께 쓸 때는 추가로 **두 카메라 사이의 상대 위치·자세(외부 파라미터)**도 구해야 합니다. 이것이 있어야 두 이미지를 픽셀 단위로 정렬할 수 있습니다.
캘리브레이션으로 얻는 결과물
결과물의미| cameraMatrix (K) | 초점거리(fx, fy), 주점(cx, cy) — 내부 파라미터 |
| distCoeffs | 렌즈 왜곡 계수 (k1, k2, p1, p2, ...) |
| R, T | 카메라1 → 카메라2 회전·평행이동 — 스테레오 외부 파라미터 |
| E, F | Essential/Fundamental Matrix — 두 카메라 간 기하학적 제약 |
왜 ChArUco 보드인가
기존 체커보드 대신 ArUco 마커가 박힌 체커보드를 사용합니다.
- 체커보드의 단점: 코너가 일부만 보이면 어느 코너인지 ID를 알 수 없음
- ChArUco의 장점: 각 코너 근처에 고유 ID 마커가 있어서, 보드가 부분적으로 잘려도 어느 코너인지 식별 가능
실제 환경에서 보드를 다양한 각도로 기울이거나 이미지 가장자리에 걸칠 때 훨씬 안정적으로 동작합니다.
전체 파이프라인
1. ChArUco 보드 제작 및 정의
↓
2. 이미지 수집 (카메라1, 카메라2 동기화 쌍)
↓
3. 각 이미지에서 ChArUco 코너 검출
↓
4. 단일 카메라 내부 파라미터 캘리브레이션 (카메라1, 카메라2 각각 독립적으로)
↓
5. 스테레오 캘리브레이션 (외부 파라미터 R, T 산출)
↓
6. 이미지 정렬 (스테레오 렉티피케이션 또는 호모그래피)
Step 1. 보드 제작과 코드 정의
보드를 인쇄한 뒤 실제 정사각형 한 변의 길이를 자로 직접 측정합니다. 이 수치가 코드와 일치하지 않으면 스케일이 틀려집니다.
ARUCO_DICT = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_250)
board = cv2.aruco.CharucoBoard_create(
squaresX=5,
squaresY=5,
squareLength=0.12, # 실제 측정값 (미터 단위)
markerLength=0.09, # ArUco 마커 크기 (squareLength보다 작아야 함)
dictionary=ARUCO_DICT
)
단위는 미터든 센티미터든 무관하지만 프로젝트 전체에서 일관성이 있어야 합니다. T(평행이동 벡터)의 단위가 여기서 결정됩니다.
Step 2. 이미지 수집 시 주의사항
- 장수: 최소 15~20쌍 이상 권장
- 다양성: 다양한 각도, 거리, 화면 내 위치에서 촬영해야 왜곡 계수 추정이 안정적입니다. 한 자세로만 찍으면 특정 방향 왜곡 추정이 부정확해집니다
- 동기화: 두 카메라의 쌍은 같은 시점에 찍혀야 합니다. 타임스탬프 차이가 크면 스테레오 외부 파라미터가 틀어집니다
- 프레임 샘플링: 연속 촬영 영상에서 추출할 경우 인접 프레임은 거의 동일하므로 일정 간격(예: 10프레임마다 1장)으로 샘플링하는 것이 효율적입니다
Step 3. ChArUco 코너 검출
검출은 두 단계로 이루어집니다.
def detect_charuco(image, board, dictionary):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
# ① ArUco 마커 검출 (어느 코너인지 ID 파악)
params = cv2.aruco.DetectorParameters_create()
corners, ids, rejected = cv2.aruco.detectMarkers(gray, dictionary, parameters=params)
if ids is None or len(ids) == 0:
return False, None, None
# ② ChArUco 코너 보간 (마커 사이 교차점을 서브픽셀 정밀도로 계산)
retval, charuco_corners, charuco_ids = cv2.aruco.interpolateCornersCharuco(
markerCorners=corners,
markerIds=ids,
image=gray,
board=board
)
if charuco_corners is None or len(charuco_ids) == 0:
return False, None, None
return True, charuco_corners, charuco_ids
ArUco 마커로 "어느 코너인지"를 파악하고, 실제 정밀 좌표는 체스판 코너(교차점)에서 얻습니다.
Step 4. 단일 카메라 내부 파라미터 캘리브레이션
두 카메라를 독립적으로 각각 캘리브레이션합니다.
ret, cameraMatrix, distCoeffs, rvecs, tvecs = cv2.aruco.calibrateCameraCharuco(
charucoCorners=all_corners, # 각 이미지의 코너 좌표 리스트
charucoIds=all_ids, # 각 이미지의 코너 ID 리스트
board=board,
imageSize=image_size,
cameraMatrix=None,
distCoeffs=None,
)
print("RMS reprojection error:", ret)
ret은 RMS 재투영 오차(reprojection error) 입니다. "3D 점을 추정된 파라미터로 다시 2D에 투영했을 때 실제 검출 위치와 얼마나 차이나는가"를 픽셀 단위로 나타냅니다.
RMS 범위상태| 0.5px 이하 | 양호 |
| 0.5 ~ 1.0px | 보통 (데이터 다양성 부족 가능성) |
| 1.0px 이상 | 데이터 품질 재검토 필요 |
Step 5. 스테레오 캘리브레이션
두 카메라가 같은 보드의 같은 코너를 동시에 본 이미지 쌍을 사용합니다. 각 쌍에서 공통으로 보이는 코너 ID를 기준으로 2D 좌표 대응을 만듭니다.
# 공통 코너 ID 추출
common_ids, idx1, idx2 = np.intersect1d(ids_cam1, ids_cam2, return_indices=True)
# 공통 코너의 3D 보드 좌표 및 각 카메라의 2D 좌표
obj_pts = board.chessboardCorners[common_ids] # (N, 3)
img_pts_cam1 = corners_cam1[idx1]
img_pts_cam2 = corners_cam2[idx2]
수집한 모든 쌍을 묶어 스테레오 캘리브레이션을 실행합니다.
retval, _, _, _, _, R, T, E, F = cv2.stereoCalibrate(
objectPoints=objp_list,
imagePoints1=pts_cam1_list,
imagePoints2=pts_cam2_list,
cameraMatrix1=cam1_mtx,
distCoeffs1=cam1_dist,
cameraMatrix2=cam2_mtx,
distCoeffs2=cam2_dist,
imageSize=image_size,
flags=cv2.CALIB_FIX_INTRINSIC,
criteria=(cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 100, 1e-5)
)
print("Stereo RMS:", retval)
CALIB_FIX_INTRINSIC이 핵심입니다. Step 4에서 구한 내부 파라미터를 고정하고 R과 T만 최적화합니다. 이것이 표준적인 2단계 접근법입니다. 모든 파라미터를 동시에 최적화하면 자유도가 너무 높아져 오히려 불안정해집니다.
Step 6. 결과 활용 — 두 가지 방법
방법 A: 스테레오 렉티피케이션
두 카메라 이미지를 에피폴라 라인이 수평이 되도록 변환합니다. 스테레오 깊이 추정이나 정밀 정렬에 적합합니다.
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
cam1_mtx, cam1_dist,
cam2_mtx, cam2_dist,
image_size, R, T,
flags=cv2.CALIB_ZERO_DISPARITY
)
map1, map2 = cv2.initUndistortRectifyMap(
cam1_mtx, cam1_dist, R1, P1, image_size, cv2.CV_32FC1
)
cam1_rectified = cv2.remap(img_cam1, map1, map2, cv2.INTER_LINEAR)
렉티피케이션 맵(map1, map2)은 한 번만 계산해 저장해두면, 이후 매 프레임마다 cv2.remap만 호출하면 됩니다.
방법 B: 호모그래피로 카메라2 → 카메라1 정렬
R, T, 두 카메라의 내부 파라미터를 알고 있으면 씬이 특정 깊이의 평면에 있다는 가정 하에 투영 변환 행렬(호모그래피 H)을 수식으로 직접 유도할 수 있습니다.
유도된 H를 사용하면 렉티피케이션 없이도 한 카메라의 이미지를 다른 카메라에 맞게 변환할 수 있습니다.
warped = cv2.warpPerspective(img_cam2, H, image_size)
주의: 이 방법은 씬이 단일 평면이라는 가정에 의존합니다. 깊이 변화가 큰 환경에서는 오정렬이 생기므로, 그런 경우에는 방법 A(렉티피케이션)를 권장합니다.
결과 저장 및 재사용
# 저장
np.savez("calib_cam1.npz", cameraMatrix=cam1_mtx, distCoeffs=cam1_dist)
np.savez("calib_cam2.npz", cameraMatrix=cam2_mtx, distCoeffs=cam2_dist)
np.savez("stereo_calib.npz", R=R, T=T, E=E, F=F)
# 불러오기
data = np.load("stereo_calib.npz")
R, T = data["R"], data["T"]
렉티피케이션 맵도 같은 방식으로 저장해두면 실시간 처리 시 재사용할 수 있습니다.
품질 점검 체크리스트
수집 단계
- 보드의 squareLength를 실제로 측정했는가
- 두 카메라 쌍의 타임스탬프 차이가 충분히 작은가
- 다양한 각도/거리/위치에서 15장 이상 촬영했는가
캘리브레이션 단계
- 단일 카메라 RMS < 0.5px
- 스테레오 RMS < 1.0px
- 언디스토션 이미지에서 직선이 실제로 직선으로 보이는가
정렬 결과 확인
- 렉티피케이션 후 에피폴라 라인이 수평으로 정렬되는가
- 두 카메라 이미지의 특징점 위치가 일치하는가
마치며
캘리브레이션에서 가장 중요한 것은 코드가 아니라 좋은 데이터를 수집하는 것입니다. 아무리 정확한 알고리즘도 한 자세로만 찍은 이미지로는 왜곡 계수를 제대로 추정하지 못합니다. 다양한 자세로 충분히 수집하고, RMS 오차로 품질을 확인하는 습관을 들이는 것이 핵심입니다.