카메라 캘리브레이션 (Camera Calibration)

카메라 캘리브레이션 (Camera Calibration)

개요

카메라 캘리브레이션은 카메라의 내부 파라미터와 렌즈 왜곡을 측정하는 과정입니다. 정확한 3D 복원, 증강현실, 로봇 비전 등에서 필수적인 단계입니다.

난이도: ⭐⭐⭐⭐

선수 지식: 기하학적 변환, 선형대수 기초, 이미지 좌표계


목차

  1. 카메라 내부 파라미터
  2. 렌즈 왜곡
  3. findChessboardCorners()
  4. calibrateCamera()
  5. undistort(): 왜곡 보정
  6. 재투영 오차
  7. 연습 문제

1. 카메라 내부 파라미터

핀홀 카메라 모델

핀홀 카메라 모델:
3D 세계 좌표를 2D 이미지 좌표로 투영

        3D 세계
           P(X, Y, Z)
              │
              │
              ▼
       ┌──────────────┐
       │    렌즈      │  ← 카메라
       └──────────────┘
              │
              │  초점 거리 f
              ▼
       ┌──────────────┐
       │ 이미지 평면  │  → p(u, v)
       │      ●       │
       └──────────────┘

투영 공식:
u = fx * (X/Z) + cx
v = fy * (Y/Z) + cy

- (X, Y, Z): 3D 점의 카메라 좌표
- (u, v): 2D 이미지 좌표 (픽셀)
- fx, fy: 초점 거리 (픽셀 단위)
- (cx, cy): 주점 (principal point)

카메라 행렬 (Intrinsic Matrix)

카메라 내부 파라미터 행렬 K:

     ┌             ┐
     │ fx   0   cx │
K =  │  0  fy   cy │
     │  0   0    1 │
     └             ┘

파라미터 설명:
┌────────────────────────────────────────────────────────────┐
│ fx, fy: 초점 거리 (focal length)                           │
│         - 픽셀 단위                                        │
│         - fx = f / pixel_width                             │
│         - fy = f / pixel_height                            │
│         - 일반적으로 fx ≈ fy (정사각 픽셀)                 │
│                                                            │
│ cx, cy: 주점 (principal point)                             │
│         - 광축이 이미지 평면과 만나는 점                   │
│         - 이상적으로는 이미지 중심 (width/2, height/2)     │
│         - 실제로는 약간의 오프셋 존재                      │
│                                                            │
│ skew: 비대칭 계수 (보통 0)                                 │
│         - 행렬의 (0,1) 위치                                │
│         - 픽셀의 비직각성                                  │
└────────────────────────────────────────────────────────────┘

예시 (일반적인 웹캠):
     ┌                    ┐
     │ 800    0    320    │
K =  │   0  800    240    │   (640x480 해상도)
     │   0    0      1    │
     └                    ┘

외부 파라미터

카메라 외부 파라미터 (Extrinsic Parameters):
카메라 좌표계와 세계 좌표계 사이의 변환

세계 좌표  카메라 좌표:

[X_cam]       [X_world]
[Y_cam] = R * [Y_world] + t
[Z_cam]       [Z_world]

R: 3x3 회전 행렬 (rotation)
t: 3x1 이동 벡터 (translation)

전체 투영:

                                            
s *   u    =    fx   0   cx  *  R | t  *  X 
      v          0  fy   cy       |       Y 
      1          0   0    1       |       Z 
                                          1 
                                                  
    이미지         카메라 행렬      외부 행렬    세계 좌표

2. 렌즈 왜곡

왜곡 종류

렌즈 왜곡 (Lens Distortion):

1. 방사 왜곡 (Radial Distortion)
   - 렌즈의 곡면으로 인해 발생
   - 배럴 (Barrel): 볼록하게 왜곡 (광각 렌즈)
   - 핀쿠션 (Pincushion): 오목하게 왜곡 (망원 렌즈)

   원본          배럴 왜곡       핀쿠션 왜곡
   ┌───────┐    ╭───────╮      ┌───────┐
   │       │    │       │      ╰       ╯
   │       │    │       │      │       │
   │       │    │       │      ╭       ╮
   └───────┘    ╰───────╯      └───────┘

2. 접선 왜곡 (Tangential Distortion)
   - 렌즈와 이미지 센서의 정렬 오차로 인해 발생
   - 이미지가 기울어지거나 비틀어짐

   ┌───────┐      ┌───────┐
   │       │      │╲      │
   │       │  →   │ ╲     │
   │       │      │  ╲    │
   └───────┘      └───────┘

왜곡 모델 수식

방사 왜곡 (Radial Distortion):

x_distorted = x * (1 + k1*r² + k2*r⁴ + k3*r⁶)
y_distorted = y * (1 + k1*r² + k2*r⁴ + k3*r⁶)

여기서:
- r² = x² + y² (정규화된 이미지 좌표에서의 거리)
- k1, k2, k3: 방사 왜곡 계수

접선 왜곡 (Tangential Distortion):

x_distorted = x + [2*p1*x*y + p2*(r² + 2*x²)]
y_distorted = y + [p1*(r² + 2*y²) + 2*p2*x*y]

여기서:
- p1, p2: 접선 왜곡 계수

왜곡 계수 벡터:
distCoeffs = [k1, k2, p1, p2, k3]

(일부 모델에서는 k4, k5, k6 추가)

왜곡의 영향

왜곡이 심한 경우의 영향:

1. 직선이 곡선으로 보임
   실제: ───────────
   왜곡: ╭─────────╮

2. 거리 측정 오류
   - 이미지 가장자리로 갈수록 오류 증가
   - 정밀 측정 불가능

3. 3D 복원 오류
   - 스테레오 비전에서 깊이 오류
   - AR 마커 위치 오류

4. 객체 인식 성능 저하
   - 템플릿 매칭 실패
   - 특징점 매칭 정확도 저하

3. findChessboardCorners()

체스보드 패턴

체스보드 패턴을 사용하는 이유:

1. 코너 검출이 정확함
2. 제작이 쉬움 (프린트 가능)
3. 평면 패턴으로 캘리브레이션 용이

체스보드 크기 정의:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│   │███│   │███│   │███│   │███│
├───┼───┼───┼───┼───┼───┼───┼───┤
│███│   │███│   │███│   │███│   │
├───┼───┼───┼───┼───┼───┼───┼───┤
│   │███│   │███│   │███│   │███│
├───┼───┼───┼───┼───┼───┼───┼───┤
│███│   │███│   │███│   │███│   │
├───┼───┼───┼───┼───┼───┼───┼───┤
│   │███│   │███│   │███│   │███│
├───┼───┼───┼───┼───┼───┼───┼───┤
│███│   │███│   │███│   │███│   │
└───┴───┴───┴───┴───┴───┴───┴───┘

내부 코너 수: (7, 5)
- 가로 7개, 세로 5개의 내부 코너
- 총 35개 코너점

주의: 체스보드 크기는 "내부 코너 수"
      칸 수가 아님!

코너 검출

import cv2
import numpy as np

# 체스보드 내부 코너 수
CHECKERBOARD = (7, 5)

# 이미지 로드
img = cv2.imread('chessboard.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 체스보드 코너 검출
ret, corners = cv2.findChessboardCorners(
    gray,
    CHECKERBOARD,
    flags=cv2.CALIB_CB_ADAPTIVE_THRESH +
          cv2.CALIB_CB_FAST_CHECK +
          cv2.CALIB_CB_NORMALIZE_IMAGE
)

if ret:
    print(f"코너 검출 성공: {corners.shape[0]}개")

    # 서브픽셀 정밀도로 코너 위치 개선
    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
    corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)

    # 코너 시각화
    img_corners = cv2.drawChessboardCorners(img, CHECKERBOARD, corners, ret)
    cv2.imshow('Corners', img_corners)
    cv2.waitKey(0)
else:
    print("코너 검출 실패")

검출 플래그 옵션

findChessboardCorners 플래그:

┌────────────────────────────────┬─────────────────────────────────┐
│ 플래그                         │ 설명                            │
├────────────────────────────────┼─────────────────────────────────┤
│ CALIB_CB_ADAPTIVE_THRESH       │ 적응형 이진화 사용              │
│                                │ (조명 변화에 강건)              │
├────────────────────────────────┼─────────────────────────────────┤
│ CALIB_CB_NORMALIZE_IMAGE       │ 이미지 정규화                   │
│                                │ (대비 개선)                     │
├────────────────────────────────┼─────────────────────────────────┤
│ CALIB_CB_FILTER_QUADS          │ 잘못된 사각형 필터링            │
│                                │ (오검출 감소)                   │
├────────────────────────────────┼─────────────────────────────────┤
│ CALIB_CB_FAST_CHECK            │ 빠른 검사로 실패 조기 판단      │
│                                │ (속도 향상)                     │
└────────────────────────────────┴─────────────────────────────────┘

권장 조합:
flags = cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_FAST_CHECK + cv2.CALIB_CB_NORMALIZE_IMAGE

여러 이미지에서 코너 수집

import cv2
import numpy as np
import glob

def collect_calibration_points(image_paths, checkerboard_size):
    """여러 이미지에서 캘리브레이션 포인트 수집"""

    # 3D 점 (세계 좌표): z=0인 평면
    objp = np.zeros((checkerboard_size[0] * checkerboard_size[1], 3),
                    np.float32)
    objp[:, :2] = np.mgrid[0:checkerboard_size[0],
                           0:checkerboard_size[1]].T.reshape(-1, 2)

    # 실제 크기 적용 (예: 각 칸이 25mm)
    square_size = 25.0  # mm
    objp *= square_size

    obj_points = []  # 3D 점들
    img_points = []  # 2D 점들
    img_size = None

    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
                30, 0.001)

    for img_path in image_paths:
        img = cv2.imread(img_path)
        if img is None:
            continue

        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        img_size = gray.shape[::-1]

        # 체스보드 코너 검출
        ret, corners = cv2.findChessboardCorners(
            gray, checkerboard_size,
            cv2.CALIB_CB_ADAPTIVE_THRESH +
            cv2.CALIB_CB_FAST_CHECK +
            cv2.CALIB_CB_NORMALIZE_IMAGE
        )

        if ret:
            # 서브픽셀 정밀도 개선
            corners = cv2.cornerSubPix(gray, corners, (11, 11),
                                       (-1, -1), criteria)

            obj_points.append(objp)
            img_points.append(corners)

            print(f"성공: {img_path}")
        else:
            print(f"실패: {img_path}")

    print(f"\n{len(obj_points)}/{len(image_paths)} 이미지 사용")
    return obj_points, img_points, img_size

# 사용 예
images = glob.glob('calibration_images/*.jpg')
obj_points, img_points, img_size = collect_calibration_points(
    images, (7, 5)
)

4. calibrateCamera()

카메라 캘리브레이션 수행

import cv2
import numpy as np
import glob

def calibrate_camera(image_folder, checkerboard_size=(7, 5),
                     square_size=25.0):
    """카메라 캘리브레이션 수행"""

    # 3D 객체 점
    objp = np.zeros((checkerboard_size[0] * checkerboard_size[1], 3),
                    np.float32)
    objp[:, :2] = np.mgrid[0:checkerboard_size[0],
                           0:checkerboard_size[1]].T.reshape(-1, 2)
    objp *= square_size

    obj_points = []
    img_points = []

    images = glob.glob(f'{image_folder}/*.jpg')
    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
                30, 0.001)

    img_size = None
    valid_images = []

    for img_path in images:
        img = cv2.imread(img_path)
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        img_size = gray.shape[::-1]

        ret, corners = cv2.findChessboardCorners(
            gray, checkerboard_size,
            cv2.CALIB_CB_ADAPTIVE_THRESH +
            cv2.CALIB_CB_FAST_CHECK +
            cv2.CALIB_CB_NORMALIZE_IMAGE
        )

        if ret:
            corners = cv2.cornerSubPix(gray, corners, (11, 11),
                                       (-1, -1), criteria)
            obj_points.append(objp)
            img_points.append(corners)
            valid_images.append(img_path)

    if len(obj_points) < 10:
        print(f"경고: 이미지 수가 적습니다 ({len(obj_points)}개)")

    # 캘리브레이션 수행
    ret, camera_matrix, dist_coeffs, rvecs, tvecs = cv2.calibrateCamera(
        obj_points,     # 3D 점들
        img_points,     # 2D 점들
        img_size,       # 이미지 크기
        None,           # 초기 카메라 행렬 (None이면 자동 계산)
        None,           # 초기 왜곡 계수
        flags=cv2.CALIB_FIX_K3  # k3 고정 (선택사항)
    )

    print(f"\n캘리브레이션 완료")
    print(f"재투영 오차: {ret:.4f} 픽셀")
    print(f"\n카메라 행렬:\n{camera_matrix}")
    print(f"\n왜곡 계수:\n{dist_coeffs.ravel()}")

    return {
        'camera_matrix': camera_matrix,
        'dist_coeffs': dist_coeffs,
        'rvecs': rvecs,
        'tvecs': tvecs,
        'reprojection_error': ret,
        'valid_images': valid_images
    }

# 사용 예
result = calibrate_camera('calibration_images', (7, 5), 25.0)

캘리브레이션 결과 저장/로드

import cv2
import numpy as np
import json

def save_calibration(filepath, camera_matrix, dist_coeffs):
    """캘리브레이션 결과 저장"""

    # NumPy 배열을 리스트로 변환 (JSON 호환)
    data = {
        'camera_matrix': camera_matrix.tolist(),
        'dist_coeffs': dist_coeffs.tolist()
    }

    with open(filepath, 'w') as f:
        json.dump(data, f, indent=2)

    print(f"저장됨: {filepath}")

def load_calibration(filepath):
    """캘리브레이션 결과 로드"""

    with open(filepath, 'r') as f:
        data = json.load(f)

    camera_matrix = np.array(data['camera_matrix'])
    dist_coeffs = np.array(data['dist_coeffs'])

    return camera_matrix, dist_coeffs

# 또는 OpenCV FileStorage 사용
def save_calibration_yaml(filepath, camera_matrix, dist_coeffs):
    """YAML 형식으로 저장"""
    fs = cv2.FileStorage(filepath, cv2.FILE_STORAGE_WRITE)
    fs.write('camera_matrix', camera_matrix)
    fs.write('dist_coeffs', dist_coeffs)
    fs.release()

def load_calibration_yaml(filepath):
    """YAML 형식에서 로드"""
    fs = cv2.FileStorage(filepath, cv2.FILE_STORAGE_READ)
    camera_matrix = fs.getNode('camera_matrix').mat()
    dist_coeffs = fs.getNode('dist_coeffs').mat()
    fs.release()
    return camera_matrix, dist_coeffs

# 사용 예
save_calibration('camera_calib.json', result['camera_matrix'],
                 result['dist_coeffs'])
camera_matrix, dist_coeffs = load_calibration('camera_calib.json')

캘리브레이션 플래그

calibrateCamera 플래그 옵션:

┌──────────────────────────────┬──────────────────────────────────┐
│ 플래그                       │ 설명                             │
├──────────────────────────────┼──────────────────────────────────┤
│ CALIB_USE_INTRINSIC_GUESS    │ 초기 카메라 행렬 사용            │
├──────────────────────────────┼──────────────────────────────────┤
│ CALIB_FIX_PRINCIPAL_POINT    │ 주점 고정                        │
├──────────────────────────────┼──────────────────────────────────┤
│ CALIB_FIX_ASPECT_RATIO       │ fx/fy 비율 고정                  │
├──────────────────────────────┼──────────────────────────────────┤
│ CALIB_ZERO_TANGENT_DIST      │ 접선 왜곡 = 0으로 고정           │
├──────────────────────────────┼──────────────────────────────────┤
│ CALIB_FIX_K1, K2, K3, ...    │ 특정 왜곡 계수 고정              │
├──────────────────────────────┼──────────────────────────────────┤
│ CALIB_RATIONAL_MODEL         │ 고차 왜곡 모델 사용 (k4,k5,k6)   │
├──────────────────────────────┼──────────────────────────────────┤
│ CALIB_FIX_S1_S2_S3_S4        │ 얇은 렌즈 왜곡 계수 고정         │
└──────────────────────────────┴──────────────────────────────────┘

일반적인 조합:
# 기본 캘리브레이션
flags = 0

# 간단한 왜곡 모델 (k1, k2만)
flags = cv2.CALIB_FIX_K3 | cv2.CALIB_ZERO_TANGENT_DIST

# 고정밀 캘리브레이션
flags = cv2.CALIB_RATIONAL_MODEL

5. undistort(): 왜곡 보정

기본 왜곡 보정

import cv2
import numpy as np

def undistort_image(img, camera_matrix, dist_coeffs):
    """이미지 왜곡 보정"""

    h, w = img.shape[:2]

    # 새 카메라 행렬 계산 (최적화된 영역)
    # alpha: 0=모든 왜곡 픽셀 제거, 1=모든 원본 픽셀 유지
    new_camera_matrix, roi = cv2.getOptimalNewCameraMatrix(
        camera_matrix, dist_coeffs, (w, h), alpha=1, newImgSize=(w, h)
    )

    # 왜곡 보정
    undistorted = cv2.undistort(img, camera_matrix, dist_coeffs,
                                 None, new_camera_matrix)

    # ROI로 크롭 (선택사항)
    x, y, w, h = roi
    if all([x, y, w, h]):  # ROI가 유효한 경우
        undistorted_cropped = undistorted[y:y+h, x:x+w]
        return undistorted, undistorted_cropped

    return undistorted, undistorted

# 사용 예
img = cv2.imread('distorted.jpg')
camera_matrix, dist_coeffs = load_calibration('camera_calib.json')
undistorted, cropped = undistort_image(img, camera_matrix, dist_coeffs)

cv2.imshow('Original', img)
cv2.imshow('Undistorted', undistorted)
cv2.imshow('Cropped', cropped)
cv2.waitKey(0)

리맵핑 방식 (더 효율적)

import cv2
import numpy as np

class UndistortMapper:
    """리맵핑 기반 왜곡 보정기 (비디오용)"""

    def __init__(self, camera_matrix, dist_coeffs, img_size, alpha=1):
        self.camera_matrix = camera_matrix
        self.dist_coeffs = dist_coeffs

        w, h = img_size

        # 새 카메라 행렬
        self.new_camera_matrix, self.roi = cv2.getOptimalNewCameraMatrix(
            camera_matrix, dist_coeffs, (w, h), alpha, (w, h)
        )

        # 리맵핑 맵 계산 (한 번만)
        self.mapx, self.mapy = cv2.initUndistortRectifyMap(
            camera_matrix, dist_coeffs, None,
            self.new_camera_matrix, (w, h), cv2.CV_32FC1
        )

    def undistort(self, img, crop=True):
        """빠른 왜곡 보정 (리맵핑 사용)"""
        undistorted = cv2.remap(img, self.mapx, self.mapy,
                                cv2.INTER_LINEAR)

        if crop and all(self.roi):
            x, y, w, h = self.roi
            return undistorted[y:y+h, x:x+w]

        return undistorted

# 비디오 처리 예
cap = cv2.VideoCapture(0)

# 첫 프레임으로 크기 확인
ret, frame = cap.read()
h, w = frame.shape[:2]

# 리맵퍼 초기화 (한 번만)
camera_matrix, dist_coeffs = load_calibration('camera_calib.json')
mapper = UndistortMapper(camera_matrix, dist_coeffs, (w, h))

while True:
    ret, frame = cap.read()
    if not ret:
        break

    # 빠른 왜곡 보정
    undistorted = mapper.undistort(frame)

    cv2.imshow('Original', frame)
    cv2.imshow('Undistorted', undistorted)

    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()

왜곡 보정 시각화

import cv2
import numpy as np
import matplotlib.pyplot as plt

def visualize_undistortion(img, camera_matrix, dist_coeffs):
    """왜곡 보정 전후 비교 시각화"""

    h, w = img.shape[:2]

    # 왜곡 보정
    undistorted = cv2.undistort(img, camera_matrix, dist_coeffs)

    # 격자 오버레이 생성
    def add_grid(image, step=50):
        result = image.copy()
        for i in range(0, image.shape[1], step):
            cv2.line(result, (i, 0), (i, image.shape[0]), (0, 255, 0), 1)
        for i in range(0, image.shape[0], step):
            cv2.line(result, (0, i), (image.shape[1], i), (0, 255, 0), 1)
        return result

    img_grid = add_grid(img)
    undistorted_grid = add_grid(undistorted)

    # 나란히 표시
    fig, axes = plt.subplots(1, 2, figsize=(14, 7))

    axes[0].imshow(cv2.cvtColor(img_grid, cv2.COLOR_BGR2RGB))
    axes[0].set_title('Original (Distorted)')
    axes[0].axis('off')

    axes[1].imshow(cv2.cvtColor(undistorted_grid, cv2.COLOR_BGR2RGB))
    axes[1].set_title('Undistorted')
    axes[1].axis('off')

    plt.tight_layout()
    plt.show()

    return undistorted

# 사용 예
img = cv2.imread('distorted.jpg')
undistorted = visualize_undistortion(img, camera_matrix, dist_coeffs)

6. 재투영 오차

재투영 오차 계산

재투영 오차 (Reprojection Error):
캘리브레이션 품질을 나타내는 지표

과정:
1. 알려진 3D 점을 캘리브레이션 결과로 2D로 투영
2. 검출된 2D 코너와의 거리 계산
3. 모든 점에 대한 평균 거리

    실제 검출 위치 ●────────● 재투영 위치
                   │ 오차   │
                   └────────┘

좋은 캘리브레이션: 재투영 오차 < 0.5 픽셀
보통: 0.5 ~ 1.0 픽셀
나쁨: > 1.0 픽셀

재투영 오차 상세 분석

import cv2
import numpy as np
import matplotlib.pyplot as plt

def calculate_reprojection_error(obj_points, img_points,
                                  rvecs, tvecs,
                                  camera_matrix, dist_coeffs):
    """상세 재투영 오차 계산"""

    errors = []
    per_image_errors = []

    for i in range(len(obj_points)):
        # 3D 점을 2D로 재투영
        projected_points, _ = cv2.projectPoints(
            obj_points[i], rvecs[i], tvecs[i],
            camera_matrix, dist_coeffs
        )

        # 오차 계산
        error = cv2.norm(img_points[i], projected_points, cv2.NORM_L2)
        error /= len(projected_points)

        per_image_errors.append(error)

        # 각 점별 오차
        for j in range(len(projected_points)):
            pt_error = np.linalg.norm(
                img_points[i][j] - projected_points[j]
            )
            errors.append(pt_error)

    mean_error = np.mean(errors)
    std_error = np.std(errors)
    max_error = np.max(errors)

    print(f"재투영 오차 통계:")
    print(f"  평균: {mean_error:.4f} 픽셀")
    print(f"  표준편차: {std_error:.4f}")
    print(f"  최대: {max_error:.4f}")

    return {
        'mean': mean_error,
        'std': std_error,
        'max': max_error,
        'per_point': errors,
        'per_image': per_image_errors
    }

def visualize_reprojection_error(error_data):
    """재투영 오차 시각화"""

    fig, axes = plt.subplots(1, 2, figsize=(12, 5))

    # 포인트별 오차 히스토그램
    axes[0].hist(error_data['per_point'], bins=50, edgecolor='black')
    axes[0].axvline(error_data['mean'], color='r', linestyle='--',
                    label=f"Mean: {error_data['mean']:.3f}")
    axes[0].set_xlabel('Reprojection Error (pixels)')
    axes[0].set_ylabel('Frequency')
    axes[0].set_title('Error Distribution')
    axes[0].legend()

    # 이미지별 오차
    axes[1].bar(range(len(error_data['per_image'])),
                error_data['per_image'])
    axes[1].axhline(error_data['mean'], color='r', linestyle='--')
    axes[1].set_xlabel('Image Index')
    axes[1].set_ylabel('Mean Error (pixels)')
    axes[1].set_title('Per-Image Error')

    plt.tight_layout()
    plt.show()

# 사용 예
# calibration 결과에서
error_data = calculate_reprojection_error(
    obj_points, img_points,
    result['rvecs'], result['tvecs'],
    result['camera_matrix'], result['dist_coeffs']
)
visualize_reprojection_error(error_data)

캘리브레이션 품질 개선

def improve_calibration(obj_points, img_points, img_size,
                        camera_matrix, dist_coeffs,
                        rvecs, tvecs, threshold=1.0):
    """높은 오차의 이미지를 제거하여 캘리브레이션 개선"""

    # 각 이미지의 재투영 오차 계산
    per_image_errors = []

    for i in range(len(obj_points)):
        projected, _ = cv2.projectPoints(
            obj_points[i], rvecs[i], tvecs[i],
            camera_matrix, dist_coeffs
        )
        error = cv2.norm(img_points[i], projected, cv2.NORM_L2)
        error /= len(projected)
        per_image_errors.append(error)

    # 임계값 이하의 이미지만 선택
    good_indices = [i for i, e in enumerate(per_image_errors)
                    if e < threshold]

    if len(good_indices) < 5:
        print("경고: 좋은 이미지가 너무 적습니다")
        return None

    # 선택된 이미지로 재캘리브레이션
    good_obj = [obj_points[i] for i in good_indices]
    good_img = [img_points[i] for i in good_indices]

    ret, new_camera_matrix, new_dist_coeffs, new_rvecs, new_tvecs = \
        cv2.calibrateCamera(good_obj, good_img, img_size, None, None)

    print(f"제거된 이미지: {len(obj_points) - len(good_indices)}")
    print(f"새 재투영 오차: {ret:.4f}")

    return {
        'camera_matrix': new_camera_matrix,
        'dist_coeffs': new_dist_coeffs,
        'reprojection_error': ret,
        'used_images': len(good_indices)
    }

실시간 캘리브레이션

import cv2
import numpy as np

class RealtimeCalibrator:
    """실시간 카메라 캘리브레이션"""

    def __init__(self, checkerboard_size=(7, 5), square_size=25.0,
                 min_images=15):
        self.checkerboard_size = checkerboard_size
        self.square_size = square_size
        self.min_images = min_images

        # 3D 객체 점
        self.objp = np.zeros(
            (checkerboard_size[0] * checkerboard_size[1], 3),
            np.float32
        )
        self.objp[:, :2] = np.mgrid[
            0:checkerboard_size[0],
            0:checkerboard_size[1]
        ].T.reshape(-1, 2)
        self.objp *= square_size

        self.obj_points = []
        self.img_points = []
        self.img_size = None

        self.calibrated = False
        self.camera_matrix = None
        self.dist_coeffs = None

        self.criteria = (cv2.TERM_CRITERIA_EPS +
                        cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)

    def process_frame(self, frame):
        """프레임 처리 및 코너 검출"""
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        self.img_size = gray.shape[::-1]

        ret, corners = cv2.findChessboardCorners(
            gray, self.checkerboard_size,
            cv2.CALIB_CB_ADAPTIVE_THRESH +
            cv2.CALIB_CB_FAST_CHECK +
            cv2.CALIB_CB_NORMALIZE_IMAGE
        )

        display = frame.copy()

        if ret:
            corners = cv2.cornerSubPix(gray, corners, (11, 11),
                                       (-1, -1), self.criteria)
            cv2.drawChessboardCorners(display, self.checkerboard_size,
                                       corners, ret)

        # 상태 표시
        status = f"Images: {len(self.obj_points)}/{self.min_images}"
        cv2.putText(display, status, (10, 30),
                   cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

        if self.calibrated:
            cv2.putText(display, "CALIBRATED", (10, 60),
                       cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

        return display, ret, corners

    def capture(self, corners):
        """캘리브레이션용 프레임 캡처"""
        if corners is not None:
            self.obj_points.append(self.objp)
            self.img_points.append(corners)
            print(f"캡처: {len(self.obj_points)}번째")

            # 충분한 이미지가 모이면 자동 캘리브레이션
            if len(self.obj_points) >= self.min_images and not self.calibrated:
                self.calibrate()

    def calibrate(self):
        """캘리브레이션 수행"""
        if len(self.obj_points) < self.min_images:
            print(f"이미지가 부족합니다: {len(self.obj_points)}/{self.min_images}")
            return False

        ret, self.camera_matrix, self.dist_coeffs, rvecs, tvecs = \
            cv2.calibrateCamera(
                self.obj_points, self.img_points,
                self.img_size, None, None
            )

        self.calibrated = True
        print(f"\n캘리브레이션 완료!")
        print(f"재투영 오차: {ret:.4f}")
        print(f"카메라 행렬:\n{self.camera_matrix}")

        return True

    def undistort(self, frame):
        """왜곡 보정"""
        if not self.calibrated:
            return frame

        return cv2.undistort(frame, self.camera_matrix, self.dist_coeffs)

# 사용 예
cap = cv2.VideoCapture(0)
calibrator = RealtimeCalibrator(min_images=15)

print("스페이스바: 캡처, c: 캘리브레이션, u: 왜곡 보정 토글, q: 종료")

show_undistorted = False

while True:
    ret, frame = cap.read()
    if not ret:
        break

    display, found, corners = calibrator.process_frame(frame)

    if show_undistorted and calibrator.calibrated:
        display = calibrator.undistort(display)
        cv2.putText(display, "UNDISTORTED", (10, 90),
                   cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)

    cv2.imshow('Calibration', display)

    key = cv2.waitKey(1) & 0xFF
    if key == ord('q'):
        break
    elif key == ord(' ') and found:
        calibrator.capture(corners)
    elif key == ord('c'):
        calibrator.calibrate()
    elif key == ord('u'):
        show_undistorted = not show_undistorted

cap.release()
cv2.destroyAllWindows()

# 결과 저장
if calibrator.calibrated:
    save_calibration('camera_calib.json',
                    calibrator.camera_matrix,
                    calibrator.dist_coeffs)

7. 연습 문제

문제 1: 캘리브레이션 이미지 자동 수집

웹캠에서 자동으로 캘리브레이션 이미지를 수집하는 프로그램을 작성하세요.

요구사항: - 체스보드 검출 시 자동 캡처 (일정 시간 간격) - 다양한 각도/위치에서 캡처되도록 안내 - 수집된 이미지 품질 확인 (블러 제거) - 최소 15-20장 수집

힌트
import time

last_capture_time = 0
min_interval = 2.0  # 최소 캡처 간격 (초)

# 블러 검출
def is_blurry(img, threshold=100):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
    return laplacian_var < threshold

# 자동 캡처 조건
if (found and
    time.time() - last_capture_time > min_interval and
    not is_blurry(frame)):
    # 캡처

문제 2: 어안 렌즈 캘리브레이션

어안 (fisheye) 렌즈 카메라를 캘리브레이션하세요.

요구사항: - cv2.fisheye 모듈 사용 - 어안 특유의 극심한 왜곡 보정 - 일반 모델과 결과 비교

힌트
# 어안 캘리브레이션
K = np.zeros((3, 3))
D = np.zeros((4, 1))
rvecs = [np.zeros((1, 1, 3), dtype=np.float64) for _ in obj_points]
tvecs = [np.zeros((1, 1, 3), dtype=np.float64) for _ in obj_points]

flags = (cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC +
         cv2.fisheye.CALIB_FIX_SKEW)

ret, K, D, rvecs, tvecs = cv2.fisheye.calibrate(
    obj_points, img_points, img_size, K, D,
    rvecs, tvecs, flags
)

# 어안 왜곡 보정
map1, map2 = cv2.fisheye.initUndistortRectifyMap(
    K, D, np.eye(3), K, img_size, cv2.CV_16SC2
)

문제 3: 스테레오 캘리브레이션

두 대의 카메라를 동시에 캘리브레이션하세요.

요구사항: - 각 카메라 개별 캘리브레이션 - 스테레오 캘리브레이션 (상대 위치 계산) - 스테레오 정류 (rectification)

힌트
# 스테레오 캘리브레이션
ret, M1, d1, M2, d2, R, T, E, F = cv2.stereoCalibrate(
    obj_points,
    img_points_left, img_points_right,
    camera_matrix_left, dist_coeffs_left,
    camera_matrix_right, dist_coeffs_right,
    img_size,
    flags=cv2.CALIB_FIX_INTRINSIC
)

# 스테레오 정류
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
    M1, d1, M2, d2, img_size, R, T
)

문제 4: 원형 패턴 캘리브레이션

체스보드 대신 원형 패턴을 사용한 캘리브레이션을 구현하세요.

요구사항: - cv2.findCirclesGrid() 사용 - 대칭/비대칭 원형 그리드 지원 - 체스보드 결과와 비교

힌트
# 원형 그리드 검출
# 대칭 그리드
ret, centers = cv2.findCirclesGrid(
    gray, (4, 11),
    flags=cv2.CALIB_CB_SYMMETRIC_GRID
)

# 비대칭 그리드 (더 정확)
ret, centers = cv2.findCirclesGrid(
    gray, (4, 11),
    flags=cv2.CALIB_CB_ASYMMETRIC_GRID
)

# 비대칭 그리드의 3D 점
objp = np.zeros((4*11, 3), np.float32)
for i in range(11):
    for j in range(4):
        objp[i*4 + j] = [j*2 + (i%2), i, 0]

문제 5: 캘리브레이션 품질 평가 도구

캘리브레이션 결과의 품질을 종합적으로 평가하는 도구를 만드세요.

요구사항: - 재투영 오차 분포 시각화 - 왜곡 계수 분석 - 이상치 이미지 검출 및 제거 - 캘리브레이션 신뢰도 점수

힌트
class CalibrationEvaluator:
    def evaluate(self, result, obj_points, img_points):
        # 재투영 오차 분포
        errors = self.compute_per_point_errors(...)

        # 이상치 검출 (2 표준편차 이상)
        outliers = errors > np.mean(errors) + 2*np.std(errors)

        # 왜곡 계수 분석
        k1, k2, p1, p2, k3 = result['dist_coeffs'].ravel()

        # 신뢰도 점수
        score = 100
        score -= min(50, result['reprojection_error'] * 50)  # 오차 페널티
        score -= min(30, outlier_ratio * 100)  # 이상치 페널티

        return {'score': score, ...}

다음 단계


참고 자료

to navigate between lessons