Vision & Inspection

[LensCal] 검출기를 갈아끼울 수 있게 만든 설계

PixelMechanic 2026. 4. 13. 17:27

전체구조

시리즈 2/7.

1편에서 "Cognex 정식 검출기와 자체 OpenCV 검출기를 같이 두고 비교 중" 이라는 얘기를 했습니다. 이게 가능한 건 두 검출기가 완전히 똑같은 결과 구조체를 채워서 돌려주도록 맞춰놨기 때문이에요. 이번 편은 그 한 조각에 대한 이야기입니다.

얼핏 "인터페이스 통일" 이라는 말로 한 줄 정리될 것 같은데, 실제로 해보면 몇 가지 미묘한 선택이 끼어 있어요. 특히 그리드 인덱스 부여를 누가 책임질 것인가 라는 부분이 이번 구조의 핵심이었습니다.

출발점 — 엔진이 알아야 하는 것과 몰라도 되는 것

캘리브레이션 엔진 (CLensCalibEngine) 이 하는 일은 대략 이렇습니다.

  • 검출된 코너 좌표들을 m_vGridMap[r][c] 라는 그리드 인덱스 맵으로 정리
  • 그 위에 CalcScaleFactor() 로 스케일 계산
  • 그 위에 재투영 오차, 직교성, 9영역 히트맵, 왜곡, 콘트라스트, 샤프니스 분석
  • 대시보드용 요약 데이터 만들기

이 중에서 검출기에 의존하는 부분은 사실상 첫 줄뿐입니다. 나머지는 "좌표 + 그리드 인덱스" 만 있으면 돌아가는 순수한 계산이에요. 즉 엔진이 검출기로부터 받고 싶은 것은 딱 두 가지입니다.

  1. 각 코너의 픽셀 좌표 (x, y)
  2. 그 코너가 "그리드의 몇 번째 열, 몇 번째 행" 인지

이 두 가지만 받으면 엔진은 내부를 뭘로 구현했든 신경 쓸 필요가 없어집니다. Cognex 든 OpenCV 든, 혹은 앞으로 추가될 또 다른 검출기든.

공통 결과 구조체 — 이미 있었던 자산

다행히 원래 Cognex 래퍼가 쓰던 결과 구조체가 이 요구를 거의 그대로 만족하고 있었습니다. (이 구조체의 내용물이 Cognex OCX 의 직접 반환이 아니라는 점은 1편에서 짚었습니다. OCX 는 좌표만 주고, 인덱스·각도·해상도는 래퍼가 계산해서 채우는 거예요.)

typedef struct {
    int     nCorner;            // 검출된 코너 수
    double* pdCornerX;          // 코너 X 좌표 배열
    double* pdCornerY;          // 코너 Y 좌표 배열
    int*    piIndexX;           // 그리드 인덱스 X
    int*    piIndexY;           // 그리드 인덱스 Y
    double  dAngle;             // 회전 각도 (degree)
    double  dResolutionX;       // X 해상도 (um/pixel)
    double  dResolutionY;       // Y 해상도 (um/pixel)
} CALIBRATION_TARGET_RESULT;

(pdCornerX, pdCornerY) 가 좌표, (piIndexX, piIndexY) 가 그리드 인덱스. 좌표 배열의 i번째 원소와 인덱스 배열의 i번째 원소가 같은 코너를 가리킵니다. 즉 "i 번째 검출 코너는 픽셀 (pdCornerX[i], pdCornerY[i]) 에 있고, 그리드 상으로는 (piIndexX[i], piIndexY[i]) 위치다" 라고 해석합니다.

이 구조체가 좀 옛날 스타일이라 double* 포인터가 여기저기 박혀 있고 메모리 소유권이 다소 애매한 감은 있어요. std::vector<Corner> 같은 현대적 형태면 더 깔끔했겠지만, 기존 HWTester 코드와의 호환성 때문에 이 모양을 유지하고 있습니다. "잘 돌아가는 인터페이스는 예쁘지 않아도 건드리지 않는다" 가 이번 작업 내내 지킨 원칙이었어요.

자체 구현 쪽에서 이 구조체를 그대로 채우기로 결정

결과 구조체가 이미 있으니 방법은 두 가지였습니다.

  1. 새 검출기용으로 더 예쁜 구조체를 따로 만들고, 엔진이 양쪽을 다 받아들이도록 고친다
  2. 새 검출기가 기존 구조체를 그대로 채운다 (엔진은 안 건드림)

저는 두 번째를 택했습니다. 이유는 단순해요. "바꾸지 않아도 되는 걸 바꾸면 그 자체가 리스크" 입니다. 엔진 쪽 코드는 지금 잘 돌아가고 있고 테스트 커버리지도 제법 쌓여 있어요. 여기를 건드리는 순간 회귀 테스트가 전부 재검증 대상이 됩니다. 반면 검출기를 교체하는 것만으로는 엔진 코드에 손을 대지 않아도 되니까, 변경 범위가 검출기 파일 두 개 안에만 갇힙니다.

// CalibrationTargetCV.h — OpenCV 기반 체커보드 코너 자동 검출 클래스
//
// Cognex 의존성 없이 OpenCV만으로 체커보드 코너를 검출하고
// 해상도(um/pixel) 및 회전 각도를 계산한다.
// 기존 CCalibrationTarget과 동일한 CALIBRATION_TARGET_RESULT를 출력하여
// LensCalibEngine에서 드롭인 교체가 가능하다.

결과적으로 엔진 쪽에서는 "어느 검출기가 왔는가" 를 아예 신경 쓸 필요가 없어졌습니다. DetectPattern() 함수 안에 있는 한 줄

CCalibrationTargetCV target;   // 또는 CCalibrationTarget (Cognex)

만 바꾸면 검출기가 교체됩니다. 이게 현재 소스 트리의 상태예요. 두 헤더가 나란히 있고, 한 줄 수정으로 전환 가능합니다.

핵심은 "그리드 인덱스를 검출기가 책임진다" 는 점

여기서 이번 편의 진짜 포인트를 얘기하고 싶습니다. 결과 구조체가 공통이라는 건 절반의 얘기예요. 나머지 절반은 "그리드 인덱스를 누가 부여하느냐" 입니다.

검출기가 좌표만 돌려주고 그리드 인덱스는 엔진이 복원하는 구조도 가능합니다. 사실 이게 더 흔한 패턴이에요. "검출기는 점만 주고, 정렬은 상위 레이어가 한다." 그런데 저는 반대로 갔습니다. 검출기가 자체적으로 인덱스까지 부여해서 돌려줍니다.

이유는 이렇습니다.

첫째, 엔진 쪽에 "좌표 → 그리드" 복원 로직이 들어가면, 검출기를 갈아낄 때마다 이 복원 로직이 새 검출기의 출력 특성에 맞춰 튜닝되어야 합니다. Cognex 가 주는 좌표 순서와 OpenCV 가 주는 좌표 순서가 미묘하게 다를 수 있고, 이걸 엔진에서 일반화해서 처리하려면 결국 조건문이 늘어나요. "어느 검출기가 왔는가를 엔진이 몰라도 된다" 는 원칙이 깨집니다.

둘째, 복원의 맥락은 검출기 내부에 있습니다. 검출기는 자기가 검출한 코너들의 분포, 예상 피치, 예상 축 방향 같은 내부 정보를 이미 알고 있어요. 이걸 외부로 내보내서 엔진이 다시 복원하게 하는 건 정보를 한 번 버렸다가 다시 주워오는 꼴입니다. 그냥 검출기가 복원까지 해버리는 게 자연스러워요.

셋째, Cognex 래퍼 (CCalibrationTarget) 는 원래부터 인덱스를 채워서 돌려주고 있었습니다. 여기에 중요한 디테일이 있는데, Cognex OCX 자체는 사실 코너 좌표만 주지 코너의 그리드 인덱스를 직접 주진 않습니다. HWTester 에서 이 클래스를 만들 때 OCX 가 찾아준 포인트들을 기반으로 래퍼 안에서 직접 격자를 복원하고 인덱스를 부여해서 결과 구조체에 담아주고 있었던 거예요. 즉 "검출기가 인덱스까지 돌려준다" 는 책임 경계는 Cognex 쪽에서 이미 이렇게 만들어져 있었습니다.

그렇다면 자체 OpenCV 버전이 똑같은 일을 하도록 맞추면 두 쪽의 책임 경계가 정확히 동일해집니다. 엔진은 어느 쪽이 왔는지 몰라도 되고, 변경할 이유도 사라져요. 반대로 "자체 OpenCV 버전은 인덱스 안 주니까 엔진에서 복원" 이라는 분기를 만들었다면, 엔진 안에 "이 검출기는 이렇게, 저 검출기는 저렇게" 라는 지저분한 if 가 들어갔을 겁니다. 저는 이미 확립되어 있던 경계를 건드리지 않는 쪽을 택했어요.

자체 OpenCV 검출기 안에서 그리드를 어떻게 복원하나 — 간단 스케치

디테일은 이번 편의 주제를 넘어가지만, 뼈대만 보여드리면 이렇습니다.

// CCalibrationTargetCV 내부 파이프라인
1) goodFeaturesToTrack 으로 전체 코너 후보 검출
2) 4사분면 명암 패턴으로 체커보드 코너만 필터링
3) cornerSubPix 로 서브픽셀 정밀도 확보
4) 최근접 이웃 거리의 중앙값 → 그리드 피치 추정
5) 이웃 방향 벡터 클러스터링 → 그리드 2축 결정
6) BFS 탐색 → 자동 그리드 인덱스 부여 ← 여기서 piIndexX/piIndexY 를 채움

핵심은 6번입니다. 한 코너를 시작점으로 삼고, 2축을 따라 이웃을 하나씩 추적하면서 (BFS) 각 코너에 (col, row) 를 부여합니다. 그리드 크기를 UI 에서 미리 받지 않아도 자동으로 범위가 결정돼요. 일부 코너가 빠져도 격자 형태만 유지되면 복원이 됩니다.

그렇게 하면 Cognex 버전과 완전히 똑같은 형태piIndexX[], piIndexY[] 가 채워져서 결과 구조체에 실립니다. 엔진 입장에서는 "Cognex 가 돌려준 인덱스인가 자체 구현이 돌려준 인덱스인가" 가 보이지 않아요.

엔진 쪽 — 맵 재배치만 한다

엔진이 검출 결과를 받으면 하는 일은 단순합니다. 인덱스를 2차원 배열 m_vGridMap[r][c] 로 재배치하는 거예요.

BOOL CLensCalibEngine::BuildGridMap(const CALIBRATION_TARGET_RESULT& tResult)
{
    int nCorner = tResult.nCorner;
    if (nCorner < 4) return FALSE;

    // 그리드 인덱스 유효성 확인 — 검출기가 이걸 안 주면 탈출
    if (tResult.piIndexX == nullptr || tResult.piIndexY == nullptr)
        return FALSE;

    // 1) 인덱스 범위 → 격자 크기 결정
    int iMinX = INT_MAX, iMaxX = INT_MIN;
    int iMinY = INT_MAX, iMaxY = INT_MIN;
    for (int i = 0; i < nCorner; i++) {
        iMinX = std::min(iMinX, tResult.piIndexX[i]);
        iMaxX = std::max(iMaxX, tResult.piIndexX[i]);
        iMinY = std::min(iMinY, tResult.piIndexY[i]);
        iMaxY = std::max(iMaxY, tResult.piIndexY[i]);
    }
    int nCols = iMaxX - iMinX + 1;
    int nRows = iMaxY - iMinY + 1;

    // 2) 좌표 배열 저장
    m_vDetectedPoints.clear();
    m_vDetectedPoints.reserve(nCorner);
    for (int i = 0; i < nCorner; i++) {
        m_vDetectedPoints.push_back(
            cv::Point2f((float)tResult.pdCornerX[i], (float)tResult.pdCornerY[i]));
    }

    // 3) 그리드 맵 채우기 — 빈 셀은 -1
    m_vGridMap.assign(nRows, std::vector<int>(nCols, -1));
    for (int i = 0; i < nCorner; i++) {
        int c = tResult.piIndexX[i] - iMinX;
        int r = tResult.piIndexY[i] - iMinY;
        if (c >= 0 && c < nCols && r >= 0 && r < nRows)
            m_vGridMap[r][c] = i;
    }
    return TRUE;
}

원래 "정렬" 같은 이름을 붙일까 하다가 "맵 재배치" 가 더 정확해서 그렇게 부르고 있어요. 진짜 정렬은 검출기 안에서 이미 끝나 있고, 엔진은 그걸 자기가 쓰기 편한 2차원 배열 형태로 옮기기만 합니다. 코드가 짧고 단순한 게 의도된 거예요. 여기가 길어지면 "검출기가 인덱스 부여를 제대로 안 했구나" 라는 신호입니다.

빈 셀 (-1) 을 허용한 이유

m_vGridMap[r][c]-1 이 들어갈 수 있게 만들었습니다. 처음엔 "모든 셀이 채워져야 정상" 이라고 생각했는데, 현장 이미지 몇 장 물려보고 바로 이 가드를 넣었어요. 이런 일이 실제로 일어납니다.

  • 가장자리 코너가 FOV 에 걸려서 일부만 보임
  • 조명 국소 어두움으로 특정 영역 검출 누락
  • 체커보드 표면에 먼지나 스크래치
  • 반사 플레어로 특정 코너가 흐림

이런 경우에 "코너 하나 빠졌으니 전체 분석 중단" 이라고 반응하는 도구는 현장에서 안 쓰입니다. 부분적으로라도 의미 있는 답을 주는 쪽이 맞아요. 그래서 후속 분석 함수들은 전부 if (idx < 0) continue; 같은 가드를 가집니다.

엔진 내부의 핵심 자료구조 두 개

정리해 보면 엔진 내부에는 결국 이 두 개가 핵심입니다.

class CLensCalibEngine {
    std::vector<cv::Point2f>       m_vDetectedPoints;   // 검출 코너 좌표
    std::vector<std::vector<int>>  m_vGridMap;          // [row][col] → 인덱스, -1=빈셀

    // (스케일, STEP 데이터, 검출기 각도 등은 생략)
};

m_vDetectedPoints[i] 가 i번째 코너의 좌표이고, m_vGridMap[r][c] 가 (r, c) 위치의 코너가 m_vDetectedPoints 의 몇 번째에 있는지를 알려줍니다. (빈 셀은 -1.)

이 둘만 있으면 이후 분석 함수 전부가 O(1) 인덱스 접근으로 끝납니다. 재투영 오차, 직교성, 9영역 히트맵, 왜곡 — 다 여기서 파생돼요. 자료구조 하나의 모양이 그 위의 분석 코드 전체의 복잡도를 결정짓는 전형적인 케이스입니다.

검출기 없이 테스트 — _UNIT_TEST 주입 경로

한 가지 더. 검출기 선택을 미뤄놓고도 엔진 쪽 분석 코드 테스트는 계속 돌려야 했습니다. 그래서 테스트 전용 주입 API 를 뚫어뒀어요.

#ifdef _UNIT_TEST
public:
    void SetDetectedPointsForTest(const std::vector<cv::Point2f>& vPoints);
#endif

테스트 코드에서는 이상 격자를 직접 만들어 주입하고, BuildGridMapFromRowMajor() 로 맵을 재구성한 뒤 분석 함수들을 돌립니다.

auto grid = MakeIdealGrid(7, 5, 10.0);
engine.SetPatternSize(7, 5);
engine.SetKnownPitch_mm(10.0);
engine.SetDetectedPointsForTest(grid);
engine.BuildGridMapFromRowMajor();

ASSERT_TRUE(engine.CalcScaleFactor());
ASSERT_NEAR(engine.GetScaleH(), engine.GetScaleV(), 1e-9);

이 경로 덕분에 Cognex OCX 나 OpenCV 검출기 중 어느 것에도 의존하지 않고 수학 검증 전용 테스트를 CI 에서 돌릴 수 있습니다. 135개 테스트가 매일 밤 검출기 없는 환경에서도 PASS 하는 상태를 유지하는 게 이 구조의 열매예요. 검출기 선택이 아직 결정 안 났는데도 그 위의 레이어를 마음 놓고 쌓을 수 있었던 이유가 바로 여기 있습니다.

이번 편을 쓰면서 다시 깨달은 것

이번 구조를 만들면서 제가 제일 잘했다 싶은 건 "바꾸지 않아도 되는 걸 바꾸지 않은" 것입니다. 결과 구조체가 좀 옛날 스타일이었지만 건드리지 않았고, 엔진 쪽 코드도 건드리지 않았어요. 변경 범위가 검출기 파일 두 개와 그 안의 알고리즘에만 갇혀 있습니다. 덕분에 검출기 교체라는 리스크 큰 작업을 변경 범위가 작은 상태로 실험할 수 있었어요.

리팩토링을 할 때 "이 김에 이것도 바꾸자" 가 정말 유혹적입니다. 근데 그 유혹에 넘어가는 순간 변경 범위가 두 배, 세 배로 부풀어요. 그러면 회귀 테스트도 두 배로 늘어나고, 롤백도 어려워지고, 결국 "이 김에 하려던 것" 이 오히려 본 작업을 방해하게 됩니다. 이번엔 그 유혹을 잘 참았던 것 같아요 (참은 게 아니라 시간 없어서 못 한 거일 수도 있습니다만, 결과적으로는 다행이었습니다).

다음 편부터는 검출기와 독립적인 레이어로 넘어갑니다. 3편에서는 이 그리드 맵 위에서 가장 먼저 계산하는 기본 지표들 — 해상도, 등방성, 9영역 균일도, 중심-주변 편차 — 을 다루겠습니다.

반응형