Vision & Inspection

[LensCal] 체커보드에서 뽑아내는 기본 지표 5가지

PixelMechanic 2026. 4. 13. 17:31

지표에 대해서

시리즈 3/7.

2편까지 해서 정렬된 그리드 (m_vGridMap) 가 준비됐습니다. 이제 여기 위에 분석 함수들을 얹기 시작할 텐데, 가장 먼저 올리는 건 화려한 지표가 아니라 기본 중의 기본입니다. 해상도, 등방성, 9영역 균일도, 중심-주변 편차. 이름만 들으면 심심해 보이지만, 뒤에 올라갈 모든 고급 지표는 결국 이 바닥 위에 서 있습니다.

기본이 흔들리면 고급 지표가 아무리 정교해도 숫자에 의미가 없어져요. 재투영 오차 0.1 px 라는 값도, 애초에 스케일 계산이 편향되어 있으면 그 0.1 px 는 그냥 위장된 거짓말입니다. 그래서 이번 편은 "당연해 보이지만 당연하지 않은" 것들에 대한 얘기입니다.

해상도 — 정의는 단순한데

가장 핵심이 되는 값이고, 수식은 한 줄이면 끝납니다.

해상도 = 타일 실제 크기 (mm) / 코너 간 픽셀 거리 (px)

구현도 어렵지 않아요. 수평과 수직을 분리해서 계산합니다.

dScaleH = dKnownPitch_mm / dMeanDistH_px;
dScaleV = dKnownPitch_mm / dMeanDistV_px;

그런데 "코너 간 픽셀 거리" 를 어떻게 뽑느냐가 실전에서는 생각보다 신경 쓸 게 많습니다. m_vGridMap[r][c] 을 이용해서 수평/수직 이웃 쌍의 거리를 전부 수집한 다음 평균을 내는데, 이때 빈 셀이 껴 있는 경우를 가드해야 해요.

for (int r = 0; r < rows; ++r) {
    for (int c = 0; c < cols - 1; ++c) {
        int i0 = m_vGridMap[r][c];
        int i1 = m_vGridMap[r][c+1];
        if (i0 < 0 || i1 < 0) continue;

        double d = cv::norm(m_vDetectedPoints[i0] - m_vDetectedPoints[i1]);
        m_vDistH.push_back(d);
    }
}

수직도 똑같이 돌립니다. 평균은 dMeanDistH_px, 표준편차는 dStdDistH_px 에 저장하고요.

H 와 V 를 굳이 분리하는 이유

처음에는 저도 "그냥 평균 하나 쓰면 되는 거 아닌가?" 생각했습니다. 근데 실장비 이미지를 몇 장 돌려보니 H 와 V 가 미묘하게 다릅니다. 같은 렌즈에서 말이에요.

원인은 여러 가지입니다. 센서의 픽셀이 완전한 정사각형이 아닐 수도 있고, 렌즈가 미세한 타원 왜곡을 가질 수도 있고, 광축이 센서에 완전 수직이 아닐 수도 있어요. 이 차이가 다음에 나올 등방성 지표의 근거가 됩니다. 만약 H 와 V 를 평균으로 합쳐버리면, 이 미세한 편차가 평균 속에 녹아 없어집니다. 문제가 있어도 안 보이는 상태가 되는 거죠.

표준편차를 함께 저장하는 이유

dStdDistH_px 는 처음에 "그냥 통계 넣어두면 좋으니까" 정도로 추가했는데, 나중에 이 값이 의외로 결정적인 역할을 하더군요. 이상적이라면 코너 간 거리는 거의 일정해야 해요. 표준편차가 지나치게 크다면 셋 중 하나입니다. 일부 코너가 오검출됐거나, 격자 정렬이 어긋났거나, 왜곡이 예상보다 크거나.

이게 왜 중요하냐면, 뒤에서 나올 재투영 오차와 이 값이 서로 다른 방향에서 같은 문제를 가리키는 경우가 있거든요. 두 지표가 동시에 경고를 내면 "아 이건 진짜 검출 문제다" 라고 확신 있게 말할 수 있습니다. 확신은 단일 지표에서 나오지 않고, 서로 독립적인 두 지표의 일치에서 나옵니다.

등방성 — 0.1% 라는 기준

X축과 Y축 해상도가 얼마나 일치하는가. 수식은 다시 한 줄입니다.

dIsotropyRatio = |dScaleH - dScaleV| / avg(dScaleH, dScaleV) × 100  (%)

판정 기준은 이렇게 잡았습니다.

enum IsotropyStatus {
    ISOTROPY_OK      = 0,   // < 0.1%
    ISOTROPY_WARNING = 1,   // 0.1% ~ 0.5%
    ISOTROPY_ERROR   = 2    // > 0.5%
};

0.1% 라는 숫자가 어디서 나왔냐면, 저희가 쓰는 텔레센트릭 렌즈의 공칭 정밀도가 대략 이 수준이거든요. 이 값 위로 올라가면 렌즈 자체의 문제든, 설치 각도 문제든 뭔가 있는 겁니다. 참고로 이건 현장 데이터로 다시 튜닝해야 할 값이긴 해요. 교과서에서 온 값이라서요.

사실 이 값은 1차 게이트입니다

등방성의 진짜 역할은 "H 와 V 가 얼마나 일치하는지" 를 보여주는 게 아니라, "이 측정을 믿어도 되는가" 의 1차 필터입니다. 등방성이 ERROR 로 뜨면 그 뒤의 재투영 오차, 직교성 값은 거의 100% 같이 나빠집니다. 원인이 렌즈나 설치 쪽에 있을 가능성이 높거든요.

그래서 대시보드에서도 등방성 배지를 가장 눈에 띄는 위치에 놓았습니다. 초록(OK) / 노랑(WARNING) / 빨강(ERROR). 운용자는 이 배지 색 하나만 봐도 "오늘 이 장비는 믿을 수 있다/없다" 를 3초 안에 판단할 수 있어요. 숫자가 0.08% 인지 0.12% 인지 구별하는 것보다, "색이 바뀌었다" 는 사실 자체가 운용자에게는 훨씬 강한 신호입니다.

9영역 균일도 — 3×3 으로 나눈 이유

이미지를 3×3 으로 나눠서 각 영역에서 독립적으로 스케일을 냅니다.

[0] TL   [1] T   [2] TR
[3] ML   [4] C   [5] MR
[6] BL   [7] B   [8] BR

영역 인덱스 결정은 정수 나눗셈 두 번이면 됩니다.

int GetRegion9(int r, int c, int totalRows, int totalCols) {
    int rBand = (r * 3) / totalRows;
    int cBand = (c * 3) / totalCols;
    return rBand * 3 + cBand;
}

그리고 각 영역에 속한 수평/수직 이웃 쌍의 거리로 국소 스케일을 냅니다. 영역 경계에 걸친 코너를 어디에 넣을지, 코너 수가 적은 영역을 어떻게 다룰지 같은 디테일이 의외로 결과에 영향을 줘요. 경험적으로는 "경계 코너는 상위 영역에 포함, 코너 수 3개 미만 영역은 평균에서 제외" 가 가장 안정적이었습니다. 이걸 정하느라 반나절쯤 씨름했어요.

왜 하필 3×3 이냐

이 질문에 답하려고 5×5, 4×4, 2×2 를 다 돌려봤습니다. 결론은 이래요.

5×5 는 영역이 작아져서 영역당 코너 수가 부족해집니다. 특히 작은 체커보드 (예: 7×5 패턴) 에서는 한 영역에 코너가 1~2개밖에 없어서 통계가 안 잡혀요. 2×2 는 너무 거칠어서 "중심이 좋은가 주변이 나쁜가" 를 구분할 수 없고요.

3×3 이 딱 "중심 / 변 / 모서리" 라는 세 그룹을 구분할 수 있는 최소 단위입니다. 이 세 그룹은 렌즈 설계에서 실제로 물리적 의미가 다릅니다. 대부분의 렌즈는 모서리가 가장 나쁘고, 변이 그 다음, 중심이 제일 깨끗해요. 이 경향을 검증할 수 있느냐가 판정 능력을 좌우합니다.

예를 들어 모서리 4칸만 이상하게 나오면 "광학적 필드 커버리지 문제" 를 의심해야 합니다. 반면 한쪽 변 3칸만 이상하면 "조명 비대칭" 일 가능성이 높아요. 이 두 진단은 전혀 다른 방향이고, 대응 방법도 다릅니다. 3×3 이 있어야 이 구분이 가능해요.

중심-주변 편차 — 9영역을 한 숫자로

9영역 균일도를 한 단계 더 압축한 지표입니다. 중앙 (C) 을 기준으로 주변 8영역이 얼마나 벗어나는지만 봅니다.

double center = m_tStepData.dRegionScaleAvg[4];
double maxDev = 0.0;
double sumDev = 0.0;

for (int i = 0; i < 9; ++i) {
    if (i == 4) continue;
    double dev = fabs(m_tStepData.dRegionScaleAvg[i] - center) / center * 100.0;
    maxDev = std::max(maxDev, dev);
    sumDev += dev;
}

m_tStepData.dCenterEdgeDevMax = maxDev;
m_tStepData.dCenterEdgeDevAvg = sumDev / 8.0;

이 지표가 특히 잘 잡는 케이스가 세 가지 있습니다.

텔레센트릭 렌즈 품질 검증 — 이론상 중심과 주변 스케일이 0.1% 이내여야 정상입니다. 그보다 크면 렌즈 불량입니다. 위에서 얘기한 "이론상 0 을 실측으로 검증" 의 한 케이스죠.

설치 각도 점검 — 광축이 기울어지면 특정 방향으로 편차가 커집니다. 예를 들어 오른쪽 영역 3개만 편차가 크다면 렌즈가 오른쪽으로 기울어져 있을 가능성이 있어요. 이걸 max 하나로 보면 "편차 크다" 만 알고 방향은 모릅니다. 그래서 대시보드에서는 이 값을 9영역 히트맵의 색상 강도로도 함께 표시합니다. 숫자 압축 + 방향성 표시 두 개를 같이 보여주는 거죠.

수차 — 방사 왜곡과는 다른 종류의 "스케일 편차" 를 잡아냅니다. 방사 왜곡은 중심에서 방사 방향으로 생기지만, 중심-주변 편차는 단순히 "영역별 스케일 차이" 라서 원인이 왜곡이 아니어도 잡힙니다.

기본 지표만으로 답할 수 없는 것

이번 편에서 소개한 지표들만으로 "이 렌즈의 스케일이 정상인가" 는 충분히 판단할 수 있습니다. 대부분의 운용 상황에서는 사실 이 기본 지표들만 봐도 답이 나와요.

그런데 한 가지 질문에는 여전히 답을 못 합니다. "격자 자체가 얼마나 반듯한가?" 평균 스케일은 정상인데 격자가 미묘하게 휘어져 있는 경우가 있거든요. 이건 기본 지표로는 안 잡힙니다. 평균이 가려버려요.

이 질문에 답하려면 개별 코너의 잔차를 봐야 하고, 그게 재투영 오차 입니다. 다음 편에서 다루겠습니다. 그리고 재투영 오차 혼자로는 또 잡지 못하는 게 있어서 직교성 도 같이 들어갑니다. 두 지표를 교차해서 봐야 진단이 결정적으로 달라지는 순간이 있는데, 그 부분이 다음 편의 하이라이트가 될 것 같네요.

반응형