
시리즈 1/7.
솔직하게 먼저 말씀드리면, 이 글을 쓰는 지금도 저는 결정을 못 했습니다. Cognex VisionPro 의 정식 체커보드 검출기를 계속 쓸지, 아니면 최근에 직접 짠 OpenCV 기반 검출기로 갈아탈지. 양쪽을 같은 엔진에 번갈아 꽂아보며 며칠째 고민 중이에요.
원래는 "리팩토링 결정 완료" 같은 깔끔한 결론으로 글을 쓰려고 했는데, 쓰다 보니 진짜 상태랑 안 맞아서 접었습니다. 대신 "지금 이런 상황에서 이런 것들을 저울질하고 있습니다" 라는 중간보고 형태로 남기려 합니다. 결정이 나면 후속 포스트로 돌아올 테니, 이번 편은 결정을 내리기 직전까지의 생각 정리라고 봐주시면 좋겠습니다.
배경 — 원래 상황
장비에 들어가는 렌즈 캘리브레이션 모듈은 원래 HWTester 에서 이식한 CCalibrationTarget 이라는 클래스를 썼습니다. 이름은 평범하지만 내부는 Cognex ICogCalibCheckerboard 기반입니다. Cognex VisionPro 라이선스가 장비에 깔려 있어서 OCX 를 호출하는 구조예요.
// CalibrationTarget.h — HWTester 원본 체커보드 캘리브레이션 클래스
// Cognex ICogCalibCheckerboard 기반 체커보드 코너 검출 + 해상도 계산.
검출 결과는 이런 구조체로 내려옵니다.
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;
여기서 중요한 건 piIndexX, piIndexY 입니다. 검출된 코너마다 "그리드에서 몇 번째 열/행인가" 가 함께 돌아와요. 이게 있으면 후속 분석 (재투영, 직교성, 9영역 히트맵 등) 이 한결 수월해집니다. 반대로 없다면 엔진 쪽에서 직접 복원해야 하죠.
여기서 짚고 가야 할 부분이 있는데, 사실 Cognex OCX 자체가 이 인덱스나 회전 각도를 직접 뱉어주는 건 아닙니다. OCX 가 돌려주는 건 코너 좌표뿐이에요. 그 좌표들을 가지고 CCalibrationTarget 래퍼 (HWTester 이식본) 가 내부에서 직접 계산합니다. 포인트들의 분포를 보고 격자 구조를 복원해서 piIndexX/Y 를 채우고, 포인트 간 방향 벡터에서 dAngle 을 뽑아내는 거죠. 그러니까 정확히 말하면 이 결과 구조체는 "Cognex OCX 의 반환값" 이 아니라 "Cognex OCX 가 찾아준 코너 + 래퍼가 그 위에 덧붙인 후처리 결과" 입니다. 이 사실이 나중에 2편 이야기의 중요한 실마리가 됩니다.
어쨌든 몇 년간 잘 돌아가는 코드였습니다. 그런데 최근 들어 제가 이걸 계속 고민하게 된 이유가 몇 가지 있었어요.
고민의 시작 — Cognex 에 종속되는 게 점점 부담스러워짐
Cognex OCX 자체가 싫은 건 아닙니다. 실제로 꽤 안정적이에요. 다만 빌드와 배포가 좀 번거롭습니다.
- CI 서버에 Cognex 라이선스를 깔 수가 없어서 자동 테스트가 반쪽이 됩니다
- 개발자 PC 마다 라이선스가 있는 것도 아니라서 "로컬에서는 일단 스킵" 같은 테스트 코드가 섞입니다
- 버전 업 할 때마다 OCX 호환성을 확인해야 합니다
- OCX 는 타입 정보가 런타임에 확정되는 부분이 있어서, 정적 분석 도구에 잘 안 잡히는 코드가 여기저기 생깁니다
이게 하나하나는 사소한데 쌓이면 꽤 귀찮아집니다. 그래서 "Cognex 의존성을 줄일 수 있다면 줄이고 싶다" 는 생각이 한동안 마음속에 있었어요. 지우는 게 아니라, 대체 가능한 옵션을 하나쯤 가지고 있고 싶었습니다.
그래서 CCalibrationTargetCV 를 만들었습니다
이게 요 근래의 작업입니다. Cognex 없이 OpenCV 만으로 같은 결과 구조체를 만들어 내는 검출기를 하나 짰어요. 핵심 아이디어는 "인터페이스만 맞춰두면 엔진 쪽은 건드릴 필요 없다" 입니다.
// CalibrationTargetCV.h — OpenCV 기반 체커보드 코너 자동 검출 클래스
//
// Cognex 의존성 없이 OpenCV만으로 체커보드 코너를 검출하고
// 해상도(um/pixel) 및 회전 각도를 계산한다.
// 기존 CCalibrationTarget과 동일한 CALIBRATION_TARGET_RESULT를 출력하여
// LensCalibEngine에서 드롭인 교체가 가능하다.
그리고 내부 알고리즘은 이렇게 잡았습니다.
1) goodFeaturesToTrack 으로 전체 코너 후보 검출
2) 4사분면 명암 패턴으로 체커보드 코너 검증 (축 정렬 + 45° 회전 양쪽 다)
3) cornerSubPix 로 서브픽셀 정밀도 확보
4) 최근접 이웃 거리 중앙값 → 그리드 피치 추정
5) 방향 클러스터링 → 그리드 2축 결정
6) BFS 탐색 → 자동 그리드 인덱스 부여
왜 cv::findChessboardCorners() 를 안 쓰고 이렇게 짰냐면 — 예전에 findChessboardCorners() 로 해봤다가 실장비 이미지에서 실패율이 불안정했던 경험이 있거든요. 조명이 살짝 치우치거나 가장자리 코너가 잘리면 통째로 실패하는 경우가 있어서, 결국 내 손으로 코너 후보부터 다시 쌓는 게 낫겠다 싶었습니다. goodFeaturesToTrack 으로 후보를 넉넉히 뽑고, 4사분면 명암 검증으로 체커보드 코너만 필터링하는 쪽이 현장 이미지에 더 견고하더군요.
이 검출기의 특징 하나를 미리 짚고 가면, 그리드 인덱스 부여까지 검출기 안에서 처리한다는 점입니다. 즉 Cognex 버전과 똑같이 piIndexX, piIndexY 를 채워서 돌려줍니다. 그래서 엔진 쪽은 어느 검출기가 결과를 줬는지 알 필요가 없어요. 이 부분이 2편에서 다룰 내용입니다.
지금 상태 — 두 검출기가 나란히 있습니다
소스 트리에는 지금 두 파일이 같이 있습니다.
core/
├─ CalibrationTarget.h/cpp ← Cognex 버전 (남아있음)
├─ CalibrationTargetCV.h/cpp ← OpenCV 버전 (추가)
└─ LensCalibEngine.cpp ← 현재 CV 버전을 기본 호출
LensCalibEngine::DetectPattern() 안을 들여다보면 CCalibrationTargetCV 를 쓰고 있는데, 한 줄만 바꾸면 Cognex 버전으로 돌아가도록 되어 있습니다. 완전히 걷어낸 게 아니에요. 의도적으로 양쪽을 번갈아 비교할 수 있는 상태로 유지하고 있습니다.
재미있는 건 UI 다이얼로그 코드예요. 로그 메시지에는 아직 "Cognex 검출..." 이라는 문자열이 남아 있습니다. 처음엔 버그로 착각했는데, 알고 보니 "검출기 선택이 아직 끝나지 않아서 로그 문구를 바꾸지 않고 두었다" 는 게 저의 무의식의 표현이었나 봅니다. 결정을 내리면 그때 로그도 일관되게 정리할 생각입니다.
판단 기준 — 제가 중요하게 보는 것
이 시점에서 판단 기준을 명확히 해두지 않으면 계속 왔다갔다 할 것 같아서, 기준을 미리 세워두었습니다. 두 가지입니다.
첫 번째, 유지보수성. 5년 뒤 이 코드를 유지보수할 사람이 저든 다른 사람이든, 얼마나 쉽게 이해하고 고칠 수 있느냐가 중요합니다. Cognex OCX 는 "내부는 블랙박스지만 Cognex 가 책임져 주는" 외주 같은 느낌이고, 자체 구현은 "내가 완전히 이해하지만 버그도 내가 끌어안아야 하는" 집밥 같은 느낌이에요. 둘 다 장단이 있습니다.
두 번째, 검출 실패가 없어야 한다. 이게 정말 중요합니다. 현장 운용에서 "가끔 실패하는데 원인을 모름" 은 치명적입니다. 운용자 입장에서는 장비 전체에 대한 신뢰가 무너지거든요. 그래서 저는 "99% 성공률 + 1% 원인 불명 실패" 보다는 "느리지만 실패 없이 돌아가는" 쪽을 훨씬 선호합니다.
정확도나 속도는 이 두 기준 아래 순위예요. 허용 범위 안에 들어오면 결정 요인이 아닙니다.
현재까지 본 것 — 정확도 비슷, 속도 OpenCV 소폭 우세
같은 이미지 여러 장을 두 검출기에 물려서 비교해 봤는데, 솔직히 말해서 정확도 차이는 현장 허용 범위 안에서 크게 차이가 없었습니다. 재투영 오차 RMS 도 둘 다 양호한 범위고, 스케일 값도 소수점 아래 몇 자리에서 차이 나는 수준이에요.
의외였던 건 속도입니다. 측정해 보니 자체 OpenCV 검출기가 소폭 빠릅니다. Cognex OCX 호출 오버헤드가 생각보다 있었던 건지, 단순 파이프라인이 더 가벼운 건지 정확한 원인은 더 파봐야 알겠지만, 일단 결과만 보면 OpenCV 쪽이 유리해요. 물론 속도 차이도 운용에 크게 영향 주는 수준은 아닙니다. "느려서 못 쓴다" 는 아니에요.
즉 정확도/속도만 보면 OpenCV 가 살짝 앞서거나 비슷한 상태입니다. 이 정도면 "갈아타자" 로 기울어야 할 것 같은데, 그럼 왜 아직 못 하고 있느냐.
그런데도 결정을 못 내리는 이유
첫 번째는 장기 신뢰성. Cognex 는 수많은 현장에서 검증된 코드입니다. 이상한 이미지가 들어와도 어떻게든 합리적으로 처리해요. 반면 자체 OpenCV 검출기는 제가 최근에 짠 코드라서, 아직 "아직 못 본 이상한 케이스" 가 어딘가에 남아 있을 가능성이 있습니다. 며칠 전 테스트에선 잘 돌았지만, 3개월 뒤 예상 못 한 조명 조건에서 실패할지도 모르는 거죠. 이 리스크를 어떻게 관리할지가 제 가장 큰 고민입니다.
두 번째는 자체 구현의 유지보수 부담. 자체 코드는 버그가 나면 제가 떠안아야 합니다. Cognex 는 버그가 나면 최소한 "Cognex 쪽에 원인이 있다" 고 주장할 수 있어요 (실제 해결까지 가는 건 별개지만, 책임 경계가 분명합니다). 자체 코드는 그런 경계 없이 전부 내 책임이에요. 이게 코드 수명 전체에 걸쳐 쌓이는 비용입니다.
세 번째는 "지금 바꿔야 하나?" 라는 질문. Cognex 로도 잘 돌아가고 있는데, 단지 CI 때문에, 단지 빌드 의존성 때문에 갈아타는 게 정말 가치가 있는 결정인지 저도 확신이 안 서요. 기술적으로 더 깨끗해지는 것과 실제 사업 가치 사이에 어느 정도 거리가 있거든요.
그래서 택한 절충 — "갈아끼울 수 있는 상태"
결국 지금 택한 방향은 "양쪽을 다 가지고 있고, 언제든 갈아끼울 수 있게 만들어 둔다" 입니다. 한 줄 수정으로 전환 가능하도록 말이에요. 이게 우유부단해서라고 하실 수도 있지만, 저는 이걸 결정을 미루는 대신 옵션을 유지하는 전략 이라고 생각하려 합니다.
덕분에 얻은 이점이 있어요. 품질 분석 레이어 (재투영 오차, 직교성, 9영역 히트맵, 왜곡, 콘트라스트, 샤프니스 등) 는 검출기와 완전히 독립적으로 쌓을 수 있게 됐습니다. 검출기 선택을 미뤄도 이 위의 작업은 계속 진행할 수 있었어요. 시리즈 3편부터 다룰 내용이 바로 이 부분입니다.
이런 구조가 가능했던 건 결과 구조체를 두 검출기가 똑같이 쓰도록 맞춰놨기 때문인데, 그 얘기가 다음 편입니다.
이번 편을 쓰면서 다시 정리한 것
블로그에 "나 이렇게 결정했어요" 를 쓰는 건 쉬운데, "아직 결정 못 했어요" 를 쓰는 건 왠지 쑥스럽습니다. 근데 엔지니어링 작업의 상당 부분은 사실 "아직 결정 못 한" 상태에서 일어나요. 완결된 이야기만 기록하면 정작 고민의 과정은 전부 사라지고, 남는 건 결과뿐입니다. 그래서 이번엔 과정을 그대로 남겨 보려 합니다.
결정이 나면 후속 포스트로 돌아올게요. 그때는 "이렇게 갔고 그 이유는 이거였습니다" 라는 정리된 버전을 쓸 수 있겠죠. 그 전까지는 시리즈의 나머지 편에서 검출기와 독립적인 품질 분석 레이어를 계속 다루게 됩니다.
다음 편에서는 두 검출기를 갈아끼울 수 있게 만든 "공통 결과 구조체 + 그리드 인덱스를 검출기가 책임지는 구조" 를 얘기하겠습니다. 이 설계 한 조각이 지금의 우유부단(?)한 상태를 가능하게 해준 핵심입니다.
'Vision & Inspection' 카테고리의 다른 글
| [LensCal] 체커보드에서 뽑아내는 기본 지표 5가지 (0) | 2026.04.13 |
|---|---|
| [LensCal] 검출기를 갈아끼울 수 있게 만든 설계 (0) | 2026.04.13 |
| [LensCal] 렌즈 캘리브레이션 엔진을 다시 짜면서 — 시리즈 소개 (0) | 2026.04.13 |
| [Vision/C++] 반복 패턴 이미지에서의 Grid Center 검출 알고리즘 구현 (0) | 2025.12.21 |
| [C++/CUDA] 90GB 대용량 버퍼풀에서 4,000개 ROI만 쏙 뽑아 초고속 어파인 변환하기 (Zero-Copy & Batch Assembly) (0) | 2025.12.07 |