
시리즈 4/7.
3편까지 해서 기본 지표 5개가 쌓였습니다. 해상도, 코너 간 거리 분포, 등방성, 9영역 균일도, 중심-주변 편차. 운용 현장에서 대부분의 판정은 이 기본 지표들로 해결됩니다.
그런데 한 가지 질문이 계속 남아요. "평균 스케일은 괜찮아 보이는데, 격자 자체가 미묘하게 휘어 있지는 않은가?" 평균이 가려버리는 케이스가 있거든요. 코너 100개 중 5개만 조금씩 엇나가 있으면, 평균은 여전히 멀쩡해 보입니다. 이 5개를 잡으려면 개별 코너의 잔차를 봐야 하고, 그게 재투영 오차 입니다.
이번 편의 진짜 핵심은 마지막의 교차 진단표입니다. 재투영 오차 혼자서도 유용하지만, 직교성을 같이 봤을 때 진단이 결정적으로 달라지는 순간이 있어요. 그 이야기가 이번 편 전체의 결론이 될 것 같네요.
재투영 오차 — 이상 격자와 몇 픽셀 차이냐
한 줄로 말하면 이 질문에 답하는 지표입니다.
"내가 검출한 코너들이, 이상적인 반듯한 격자와 얼마나 일치하는가?"
기본 지표가 평균을 본다면, 재투영 오차는 개별 코너의 잔차 를 봅니다. 결과적으로 "이 검출을 믿어도 되는가" 의 최종 게이트 역할을 합니다.
계산 흐름은 이렇습니다.
1) 이상 격자 생성
ideal[r][c] = (c · pitch, r · pitch)
→ rows × cols 개의 완벽한 격자 점
2) Similarity Transform 추정 (스케일 + 회전 + 이동)
M = cv::estimateAffinePartial2D(ideal_points, detected_points)
3) 이상 격자에 변환 적용
projected[i] = M × ideal[i]
4) 각 코너의 잔차
error[i] = || detected[i] - projected[i] ||₂
5) 통계
RMS = sqrt(mean(error[i]²))
Max = max(error[i])
수식만 보면 단순한데, 이 중에서 2번 Similarity Transform 선택이 이번 편에서 가장 하고 싶은 얘기입니다.
왜 Similarity Transform 인가 — 자유도 선택 이야기
이상 격자는 원점 기준의 가상의 반듯한 점들입니다. 실제 검출 코너와 비교하려면 위치/크기/각도를 맞춰야 하는데, 이때 쓸 수 있는 변환이 여러 종류 있어요. 저는 처음에 Affine 을 쓸 뻔했습니다. 그리고 이게 정말 큰 실수가 될 뻔했어요.
자유도 선택에 따라 결과가 이렇게 달라집니다.
| 변환 | 자유도 | 이런 효과 |
|---|---|---|
| Euclidean (회전 + 이동) | 3 | 스케일 차이를 잔차로 치환해서 부풀림 |
| Similarity (+ 균등 스케일) | 4 | 스케일 맞춘 후 순수 격자 오차만 잔차로 |
| Affine (+ 비균등 + 전단) | 6 | 등방성 불량을 변환이 흡수 |
| Homography | 8 | 왜곡까지 흡수 — 본말전도 |
Euclidean 은 부족합니다. 스케일을 안 맞추면 이상 격자와 실제 검출의 "단위" 가 달라서 잔차가 그냥 단위 차이로 채워집니다. 이건 격자 왜곡 정보가 아니에요.
Affine 이 진짜 함정입니다. 자유도가 많아지니까 변환 자체가 왜곡을 흡수해버립니다. 특히 Affine 은 비균등 스케일과 전단을 모두 흡수하기 때문에, 등방성 불량이 잔차로 드러나지 않습니다. 처음에 저는 "자유도가 많을수록 잘 맞춰주니까 좋은 거 아니냐" 라고 생각했다가, 합성 이미지 테스트를 해보고 나서 경악했어요. 일부러 k1 = 1e-4 로 왜곡을 넣었는데 잔차가 거의 0 이 나옵니다. 변환이 왜곡을 다 먹어버린 거죠. 보려던 걸 못 보게 만드는 변환을 골랐던 겁니다.
Similarity 는 딱 맞습니다. 스케일은 이미 CalcScaleFactor() 에서 측정했으니까, 재투영 오차에서는 "그 스케일을 빼고 남는 순수한 격자 뒤틀림" 만 보고 싶은 거예요. 균등 스케일만 맞춰주고, 그 이상은 잔차로 남겨야 합니다.
OpenCV 에서 이걸 해주는 함수가 cv::estimateAffinePartial2D() 입니다. 이름에 "Affine" 이 들어가 있어서 저도 한참 헷갈렸는데, Partial 이 붙으면 Similarity Transform 이라는 뜻이에요. 문서를 처음 볼 때 이 차이를 놓치기 쉽습니다. 저만 그런 건지는 모르겠는데, 한 번 데고 나서 기억하게 된 포인트입니다.
RMS 와 Max 를 둘 다 저장하는 이유
// STEP 4
double dReprojErrorRMS; // 전체 RMS (pixels)
double dReprojErrorMax; // 최대 단일 코너 오차 (pixels)
처음엔 RMS 만 저장했어요. 그런데 현장에서 한 번 이런 케이스를 봤습니다. RMS 는 0.25 px 로 아주 양호한데 측정값이 이상하게 들쭉날쭉한 거예요. 찾아보니 코너 하나가 Max 2.3 px 로 튀고 있었습니다. 나머지가 전부 좋아서 RMS 에 가려졌던 거죠.
원인은 체커보드 표면의 먼지였습니다. 특정 코너 위치에 먼지가 앉아서 그 코너만 오검출되고 있었던 거예요. 이런 "국소 이상치" 는 평균 지표로는 절대 못 잡습니다. 그때 이후로 Max 를 반드시 같이 저장하고, 대시보드에도 둘 다 띄우도록 바꿨어요. 평균이 좋아도 Max 가 튀면 뭔가 있는 겁니다.
판정 기준은 대략 이렇게 잡았습니다.
RMS ≤ 0.3 px : 우수 (운용 품질)
0.3 ~ 1.0 px : 보통 (경고, 모니터링 필요)
> 1.0 px : 불량
이 수치는 교과서 값에 가까우니까 현장 데이터로 재튜닝 예정입니다.
격자 회전 각도 — 검출기 값과 교차 검증
이건 간단합니다. 체커보드가 이미지 축에 대해 얼마나 기울어져 있는지를 계산합니다.
int i0 = m_vGridMap[0][0];
int i1 = m_vGridMap[0][cols - 1];
double dx = m_vDetectedPoints[i1].x - m_vDetectedPoints[i0].x;
double dy = m_vDetectedPoints[i1].y - m_vDetectedPoints[i0].y;
double angle_deg = atan2(dy, dx) * 180.0 / CV_PI;
이 값 자체가 얼마나 대단한 정보를 주는 건 아닙니다. 그런데 한 가지 용도가 있어요. 검출기가 자체적으로 계산해서 돌려준 회전 각도 (m_dCogAngle) 와 비교합니다. 두 값이 크게 다르면 그리드 맵 재배치 단계에서 뭔가 꼬였다는 뜻이에요. 예를 들어 검출기가 부여한 인덱스에 순서 오류가 있거나, 일부 코너가 잘못된 행/열에 배치된 경우.
이런 교차 검증은 싸고 효과적입니다. "이 값은 저 값과 거의 같아야 한다" 는 invariant 가 하나라도 있으면, 그걸로 검출 단계의 건강 상태를 언제든 점검할 수 있거든요. 저는 가능하면 이런 invariant 를 여러 개 박아두려고 해요. 나중에 이상이 생기면 어느 단계에서 꼬였는지 빨리 찾을 수 있습니다.
직교성 — RMS 가 못 잡는 걸 잡는다
각 코너에서 수평 이웃 벡터와 수직 이웃 벡터의 사이각이 얼마나 90° 에 가까운지를 봅니다.
for (int r = 0; r < rows - 1; ++r) {
for (int c = 0; c < cols - 1; ++c) {
int i0 = m_vGridMap[r][c];
int iR = m_vGridMap[r][c+1];
int iD = m_vGridMap[r+1][c];
if (i0 < 0 || iR < 0 || iD < 0) continue;
cv::Point2f p = m_vDetectedPoints[i0];
cv::Point2f vH = m_vDetectedPoints[iR] - p;
cv::Point2f vV = m_vDetectedPoints[iD] - p;
double cosT = vH.dot(vV) / (cv::norm(vH) * cv::norm(vV));
double theta_deg = acos(cosT) * 180.0 / CV_PI;
double ortho_err = fabs(90.0 - theta_deg);
// mean / max 업데이트
}
}
판정 기준은 평균 0.1° 이하면 우수, 0.5° 넘어가면 불량으로 잡았습니다.
이 지표가 왜 필요하냐
재투영 오차 RMS 만 있으면 안 되나요? 저도 처음엔 그렇게 생각했습니다. 근데 RMS 혼자서는 체계적 방향성 을 못 잡아요.
예를 들어 렌즈가 한 방향으로 살짝 비틀려 있으면, 모든 코너가 같은 방향으로 미세하게 밀립니다. 이 경우 Similarity Transform 이 평행이동 성분을 흡수해버리기 때문에 RMS 로는 그냥 "작은 잔차" 로만 나와요. 사실은 렌즈 자체가 문제인데 "검출이 약간 흔들렸나 보다" 정도로 착각하게 됩니다.
반면 직교성은 각 코너의 로컬 구조를 봅니다. "이 코너에서 오른쪽 이웃과 아래쪽 이웃이 진짜로 90° 인가." 이건 렌즈의 접선 왜곡이나 설치 비틀림에 극도로 민감합니다. 로컬 정보라서 전역 변환이 흡수할 수가 없거든요.
직교성을 추가하고 나서 그 전엔 못 보던 케이스가 잡히기 시작했습니다. "RMS 는 0.28 로 양호한데 직교성이 0.35° 로 경고" 라고 나오는 상황. 이게 뭘 뜻하냐면, 이 렌즈는 검출은 잘 되는데 렌즈 자체에 비대칭 왜곡이 있다는 거예요. RMS 만 봤다면 그냥 OK 를 줬을 텐데, 직교성 덕분에 이상을 잡을 수 있었습니다.
이 편의 하이라이트 — 교차 진단표
이 두 지표를 나란히 놓고 조합해서 보면 이런 진단이 가능해집니다.
| RMS | 직교성 | 진단 | 1차 대응 |
|---|---|---|---|
| 낮음 | 낮음 | 정상 | 진행 |
| 낮음 | 높음 | 렌즈 비대칭 / 접선 왜곡 | 렌즈 설치 각도 점검 |
| 높음 | 낮음 | 검출 품질 문제 | 조명·초점·체커보드 표면 확인 |
| 높음 | 높음 | 렌즈 자체 이상 또는 심각한 설치 오류 | 렌즈 교체 또는 전면 재설치 |
직교성 축을 추가하기 전까지 저는 RMS 만 보고 "검출 이상이냐 렌즈 이상이냐" 의 두 가능성 사이에서 매번 헤맸습니다. 같은 증상에 원인이 두 개니까, 대응이 매번 찍기가 되는 거예요. "일단 조명부터 고쳐볼까, 안 되면 렌즈를 건드려볼까" 이런 식으로요.
직교성이 들어오자마자 판단이 결정적으로 바뀌었습니다. 두 축을 교차하니까 단일 축으로는 풀 수 없던 케이스가 구분됩니다. 지표를 "하나 더 추가할지 말지" 고민될 때 저는 요즘 이 기준으로 판단해요. 기존 지표로 판정 못 하던 케이스를 이 지표가 결정적으로 판정할 수 있는가? 그렇다면 추가할 가치가 있고, 아니면 그냥 노이즈입니다.
이 편에서 다룬 구조체 필드
// STEP 4
double dReprojErrorRMS;
double dReprojErrorMax;
// STEP 5
double dGridAngle;
double dOrthogonalityMean;
double dOrthogonalityMax;
다음 편은 광학 지표로 넘어갑니다. 방사 왜곡, 콘트라스트, 샤프니스. 특히 "텔레센트릭 렌즈인데 왜곡을 굳이 재나요?" 라는 질문을 직접 던지고 답해보려고 합니다. 제가 실제로 받은 질문이기도 하고, 답을 정리하고 나니 캘리브레이션 도구의 존재 이유 자체에 대한 생각이 좀 정리되더군요.
'Vision & Inspection' 카테고리의 다른 글
| [LensCal] 숫자보다 히트맵 — 현장에서 실제로 쓰이는 대시보드 만들기 (0) | 2026.04.13 |
|---|---|
| [LensCal] 텔레센트릭 렌즈에도 왜곡 측정이 필요한 이유 — 광학 품질 3종 세트 (0) | 2026.04.13 |
| [LensCal] 체커보드에서 뽑아내는 기본 지표 5가지 (0) | 2026.04.13 |
| [LensCal] 검출기를 갈아끼울 수 있게 만든 설계 (0) | 2026.04.13 |
| [LensCal] Cognex 정식 검출기 vs 자체 OpenCV 검출기, 아직 고민 중입니다 (0) | 2026.04.13 |