Files
Dongubak b1a38f36a3 Add camera exposure design notes and Q&A grounded in actual hardware specs
Design notes for multi-camera + LiDAR SLAM exposure tuning, plus a
question/answer pair that verifies the notes' assumptions (gain ceiling,
lens sweet spot, blur budget, 12-bit raw capture) against the actual
MV-CS016-10UC camera and VM0420MP5 lens datasheets and the driver source.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:24:04 +09:00

20 KiB
Raw Permalink Blame History

질문지 답변

question_1.md 5개 질문에 대한 답변. camera_exposure_design_notes.md의 일반론을 이 저장소의 실제 구현(hik_camera_ros2_driver/src/hik_camera_node.cpp)과 실제 하드웨어 데이터시트(resource/에 첨부된 카메라·렌즈 매뉴얼)로 검증했다.

사용한 근거 자료:

  • 카메라: resource/MVCS01610UMUC USB3.0 Area Scan Camera_datasheet_20250826 1.pdf (MV-CS016-10UC, 실제 장착 모델)
  • 렌즈: resource/VM0420MP5 en.pdf (ZLKC VM0420MP5, 4mm F2.0)
  • 드라이버 소스: src/hik_camera_ros2_driver/src/hik_camera_node.cpp
  • 현재 설정값: src/hik_camera_ros2_driver/config/camera_params_cam{1,2,3}.yaml
  • 실측 캘리브레이션: src/hik_camera_ros2_driver/config/JUL8_calib.md

1. 노출 하한 100 µs가 맞는가

결론: 더 낮출 수 있다. 하드웨어 스펙상 표준 모드에서 15 µs까지 가능하다.

카메라 데이터시트(MV-CS016-10UM/UC) 사양표:

모드 범위
UltraShort exposure mode 1 µs ~ 14 µs
Standard exposure mode 15 µs ~ 10 sec

현재 3대 모두 exposure_auto_min: 100.0 (µs)로 설정되어 있다 (camera_params_cam1/2/3.yaml). 이는 하드웨어가 지원하는 Standard 모드 하한(15 µs)보다 6.7배 높은 값이며, 별도 모드 전환 없이 그냥 파라미터 값만 낮추면 되는 영역이다.

현재 하한 100 µs → 데이터시트 표준모드 하한 15 µs
Δstop = log2(100/15) = 2.74 stop 추가 확보 가능

design note 1.3절의 "8.30 stop 예산, 여유 사실상 0" 진단에 그대로 대입하면:

기존: 노출 100→5000 µs (5.64 stop) + 게인 0→16 dB (2.66 stop) = 8.30 stop
개선: 노출  15→3000 µs (7.64 stop, 상한은 3.1절 블러 계산으로 재설정) + 게인 동일
     = 5.64 → 7.64 stop 로 노출 쪽에서만 2 stop 여유 증가

코드상 이 값의 실제 상한/하한은 카메라에서 직접 쿼리한다:

// hik_camera_node.cpp:176, 186-191
MV_CC_GetFloatValue(camera_handle_, "ExposureTime", &f_value);
param_desc.integer_range[0].from_value = static_cast<int64_t>(f_value.fMin);
param_desc.integer_range[0].to_value   = static_cast<int64_t>(f_value.fMax);
exposure_auto_min_ = this->declare_parameter("exposure_auto_min", 100.0, param_desc);

즉 ROS 파라미터 허용 범위 자체가 f_value.fMin이므로, 100 µs보다 낮은 값을 넣어도 카메라가 거부하지 않을 가능성이 높다 (실제 fMin이 15 µs 근방이라면). 배포 전 개체별 실측을 위해 f_value.fMin/fMax를 한 줄 로그로 찍어 3대 각각 확인하는 것을 권장한다 (데이터시트 값은 스펙 상 보증치이고, 개체 편차가 있을 수 있음).

권장 조치: exposure_auto_min을 15~30 µs 수준으로 낮춰서 3대 모두 재테스트. 코드 수정 없이 yaml/launch 파라미터 변경만으로 가능.


2. 게인 상한이 16 dB 근처가 맞는가

결론: 맞다. 그리고 이건 여유를 둔 값이 아니라 하드웨어 한계에 거의 붙은 값이다. 24 dB는 이 카메라에서 물리적으로 불가능하다.

데이터시트 사양표:

Gain: 0 dB to 17 dB

camera_params_cam1/2/3.yaml에 이미 실측으로 남겨둔 주석과 정확히 일치한다:

gain: 12.0   # Range: 0.0 ~ 16.9, Unit: dB   (cam1, cam3)
gain: 15.0   # Range: 0.0 ~ 16.9, Unit: dB   (cam2)

이 범위는 코드가 실행 시점에 카메라로부터 직접 읽어온 값이다 (hik_camera_node.cpp:201-203, MV_CC_GetFloatValue(camera_handle_, "Gain", &f_value)f_value.fMax). 즉 16.9 dB는 추측이 아니라 3대 카메라 모두에서 동일하게 확인된 아날로그 게인 스테이지의 실제 상한이다.

design note가 "여유"로 제시했던 g_max = 24 dB는 이 센서(Sony IMX273)에서 낼 수 없는 값이다. 정정하면:

항목 design note 가정 실측
게인 상한 24 dB (3.99 stop) 16.9 dB (2.81 stop)
f/4 + 게인상한 커버 다크사이드 EV 7.65 (직사광~밝은실내) 약 8.8~8.9 (밝은 실내 초반까지)

즉 design note 1.4절의 "f/4 + 게인 24 dB → EV 7.65까지 커버" 시나리오는 이 카메라로는 실현 불가능하고, 실제로는 조리개를 더 열거나(f/2.8) 노출 상한을 늘리는 쪽(블러 허용 한도 내에서) 으로만 어두운 쪽 stop을 추가로 확보할 수 있다. design note가 "현재" 계산에 썼던 16 dB 자체는 이미 실측치에 근접한 값이라 그 부분 계산은 유효하다 — 틀린 건 "더 열 수 있다"는 가정(24 dB) 쪽이다.

실무적으로 더 급한 문제: cam2는 지금 gain: 15.0으로, 하드웨어 상한(16.9)까지 겨우 1.9 dB(0.3 stop) 남아 있다. 실내 진입 시 게인으로 흡수할 여유가 사실상 없는 카메라가 이미 1대 있다는 뜻이므로, 조리개/노출 쪽 대책(1번 항목, 3.1절 블러 계산)이 게인보다 먼저 손봐야 할 우선순위다.


3. 12-bit raw + 프레임별 메타데이터 저장이 가능한가

두 가지를 나눠서 봐야 한다: 카메라/SDK가 지원하는가(가능)와 지금 이 드라이버가 실제로 그렇게 발행하는가(불가능, 코드 수정 필요).

3.1 "12-bit raw"가 정확히 뭘 의미하는가

이 카메라(IMX273)는 ADC를 8/10/12-bit로 선택해서 읽을 수 있다. 지금 3대 모두:

adc_bit_depth: "Bits_8"     # 8-bit = 256단계로 양자화해서 센서에서 내보냄
pixel_format: "BayerRG8"    # 컬러 베이어 모자이크, 아직 디모자이킹 전

Bits_12로 바꾸면 센서 ADC가 4096단계로 픽셀값을 내보낸다. "raw"는 여기에 추가로 디모자이킹(컬러 보간)·감마·화이트밸런스를 카메라/드라이버가 적용하지 않은 상태를 뜻한다 — 이 처리들을 나중에 PC에서 원하는 방식으로(톤매핑 알고리즘을 여러 번 바꿔가며) 다시 할 수 있다는 게 핵심 가치다 (design note 6.1~6.2절 참조).

데이터시트 기준 이 카메라(UC, 컬러)가 지원하는 raw 포맷:

Mono 8/10/12, Bayer RG 8/10/10Packed/12/12Packed, YUV422Packed, RGB8, BGR8

BayerRG12Packed가 명시적으로 지원된다. 여기까지는 카메라 하드웨어 레벨에서 가능하다.

3.2 그런데 지금 드라이버는 무조건 8-bit RGB로 변환해서 내보낸다

hik_camera_node.cpp를 보면 목적 포맷이 초기화 시점에 고정되어 있다:

// hik_camera_node.cpp:132-134 (initializeCamera, 파라미터 선언보다 먼저 실행됨)
convert_param_.nWidth = img_info_.nWidthValue;
convert_param_.nHeight = img_info_.nHeightValue;
convert_param_.enDstPixelType = PixelType_Gvsp_RGB8_Packed;   // ← 항상 이걸로 변환
// hik_camera_node.cpp:499-505, 511 (captureLoop, 매 프레임)
convert_param_.pSrcData = out_frame.pBufAddr;
convert_param_.enSrcPixelType = out_frame.stFrameInfo.enPixelType;
MV_CC_ConvertPixelType(camera_handle_, &convert_param_);       // Bayer/Mono → RGB8로 변환
...
image_msg_.encoding = "rgb8";
image_msg_.step = out_frame.stFrameInfo.nWidth * 3;             // 3 byte/px 고정

pixel_format/adc_bit_depth 파라미터는 센서가 내보내는 원본(변환의 소스)만 바꿀 뿐, 최종적으로 ROS 토픽·bag에 실리는 이미지는 adc_bit_depth: Bits_12로 설정해도 항상 8-bit 3채널 RGB로 양자화·디모자이킹된 후의 데이터다. 즉:

지금 이 드라이버로는 코드를 고치지 않는 한 12-bit raw가 bag에 담기지 않는다. 이게 design note 6.1절이 걱정하는 "정보 손실"이 실제로 지금 일어나고 있는 지점이다 (카메라가 아니라 ROS 드라이버 단에서).

필요한 수정 (개략): pixel_format이 12-bit raw 계열일 때는 MV_CC_ConvertPixelType 변환을 건너뛰고 SDK가 준 원본 버퍼를 그대로 발행. ROS sensor_msgs/Image에는 12-bit로 압축 패킹된 인코딩이 표준으로 없으므로, 실무적으로는 12-bit 값을 16-bit 컨테이너에 담는 mono16 / bayer_rggb16 인코딩(값은 하위 12비트만 사용, 상위 4비트는 0)으로 발행하는 것이 cv_bridge/rqt_image_view 등과 바로 호환되어 가장 무난하다.

3.3 프레임별 메타데이터 — 사실 이미 대부분 손에 들어와 있다

design note 5.3-[B]는 "GenICam Chunk Data(ChunkModeActive)를 켜야 프레임별 노출/게인이 붙어온다"고 가정하는데, 이 SDK는 그럴 필요가 없다. 캡처 루프가 이미 매 프레임 받아오는 MV_FRAME_OUT_INFO_EX 구조체(out_frame.stFrameInfo) 안에 이미 다음 필드가 들어있다 (hikSDK/include/CameraParams.h:182-231):

float fGain;              // 이 프레임에 실제 적용된 게인
float fExposureTime;      // 이 프레임에 실제 적용된 노출시간
unsigned int nFrameNum;   // 프레임 번호 — 드롭 검출용
unsigned int nAverageBrightness;
unsigned int nRed, nGreen, nBlue;   // 화이트밸런스
unsigned int nTriggerIndex;
unsigned int nLostPacket;

그런데 현재 captureLoop()(hik_camera_node.cpp:482-576)는 이 구조체에서 nDevTimeStampHigh/Low, nWidth, nHeight, nFrameLen, enPixelType만 읽고 나머지는 그냥 버린다.

이게 특히 중요한 이유: cam1/cam2/cam3 전부 exposure_auto: true(Continuous AE)로 돌고 있다. 즉 프레임마다 실제 노출시간이 계속 바뀌는데, 그 실제값이 지금 bag 어디에도 남지 않는다 (3초 주기 콘솔 INFO 로그로만 잠깐 찍힘). design note가 강조하는 "AE는 명령과 실제 적용 사이 1~2프레임 지연이 있으므로 설정값이 아니라 실측값을 써야 한다"는 문제를 지금 데이터로는 사후에 풀 수 없다는 뜻이다.

→ "카메라 설정만이라도 저장 가능한가"에 대한 답: 가능하고, 오히려 design note가 가정한 것보다 훨씬 쉬운 작업이다. GenICam Chunk 기능을 새로 켤 필요 없이, 이미 파싱되어 들어오는 fExposureTime/fGain/nFrameNum 등을 읽어서 별도 토픽(작은 커스텀 메시지)이나 CSV 사이드카로 흘려보내기만 하면 된다. 드라이버에 몇 줄 추가하는 수준의 저위험 변경.

3.4 용량 재계산 — 12-bit raw가 오히려 더 작아질 수 있다

지금 실측: 1440×1080 해상도(JUL8_calib.md 캘리브레이션 결과와 일치), 3대, 트리거는 LiDAR(mid360, 10 Hz)와 맞춘 것으로 보이며 acquisition_frame_rate 기본값도 10 Hz — 이를 가정해서 계산한다 (STM32 PWM 실제 주파수로 재확인 권장).

포맷 byte/px 프레임당 (3대) 11분(660s) 카메라 데이터량
현재: RGB8Packed (디모자이킹 후 발행) 3.0 14.0 MB ≈ 92.4 GB
BayerRG12Packed (raw, packed) 1.5 7.0 MB ≈ 46.2 GB
Mono16/bayer_rggb16 (raw, ROS 표준 인코딩) 2.0 9.3 MB ≈ 61.6 GB

(계산: 1440×1080 = 1,555,200 px × byte/px × 3대 × 10 Hz × 660 s)

현재 92.4 GB는 사용자가 보고한 "11분에 약 100 GB"와 mid360(10 Hz 포인트클라우드)·IMU(200 Hz) 오버헤드를 더하면 거의 일치한다 — 10 Hz 가정이 대체로 맞는 것으로 보인다.

반직관적이지만 중요한 결론: 지금 드라이버는 베이어 원본(1 byte/px)을 강제로 디모자이킹해서 RGB8(3 byte/px)로 3배 부풀린 뒤 저장하고 있다. 12-bit raw로 전환하면 정보량(256→4096단계)은 늘지만, 파일 크기는 오히려 지금보다 33~67% 줄어든다. zstd/lz4 무손실 압축(design note 6.4절)까지 적용하면 상위 4비트가 항상 0인 Mono16 컨테이너는 더 잘 압축된다.


4. 측광 지표 개선 — "하늘 있으면 바닥이 어두워지는" 현상

design note 4.1절이 정확히 이 증상을 설명한다. 실제 구현을 보면 왜 그런지 명확하다.

현재 AE는 카메라 온보드 Continuous AE를 그대로 쓴다:

// hik_camera_node.cpp:326-328
MV_CC_SetEnumValue(camera_handle_, "ExposureAuto", 2);  // Continuous
MV_CC_SetIntValue(camera_handle_, "AutoTargetBrightness", ...);

컨트롤 변수는 스칼라 하나(AutoTargetBrightness: cam1=110, cam2=128, cam3=145)뿐이고, 이건 프레임 전체의 평균 밝기를 이 값에 맞추는 방식이다 — design note 4.1절이 지적하는 "하늘(DN 255)이 평균을 끌어올려서 지면이 뭉개지는" 문제와 정확히 같은 구조.

SDK 헤더를 뒤져봐도 이 카메라가 "AE 통계 계산에만 쓰는 별도 관심영역(ROI)"을 지원한다는 근거는 없다. 찾은 AOI 관련 함수는:

MV_CC_GetAOIoffsetX/Y, MV_CC_SetAOIoffsetX/Y

이건 촬영 자체를 크롭하는 AOI(센서에서 어느 영역을 읽어올지)이지, "화각은 그대로 두고 AE 계산에서만 하늘을 빼는" 기능이 아니다. (다만 이 SDK는 상당수 GenICam 기능을 전용 C 함수 없이 문자열 노드명으로 설정하는 방식이라 — 코드의 MV_CC_SetEnumValueByString(handle, "TriggerMode", ...) 패턴처럼 — 카메라 자체가 별도 노드로 AE ROI를 지원할 가능성을 100% 배제할 순 없다. Hikvision MVS 프로그램의 Feature Tree에서 한 번 확인해볼 가치는 있음.)

→ 실무적 결론: 카메라 온보드 AE로는 ROI 측광을 구현하기 어렵다. 대신 온보드 AE를 끄고 외부 루프로 대체하는 게 맞는 방향이고, 이 드라이버는 그걸 위한 훅을 이미 갖고 있다.

// hik_camera_node.cpp:601-620 (dynamicParametersCallback)
if (name == "gain") { ... }
else if (name == "exposure_time") { ... }   // exposure_auto=false일 때 즉시 반영됨

exposure_auto: false로 두고 exposure_time/gain은 런타임에 ros2 param set으로 바꿀 수 있다. 즉 design note 4.2절(ROI + 퍼센타일)이나 4.3~4.4절(gradient 기반)의 노출 컨트롤러를 드라이버 코드를 건드리지 않고, /camX/image를 구독해서 지표를 계산하고 파라미터 서비스로 exposure_time/gain을 되쓰는 별도의 작은 ROS 2 노드로 구현할 수 있다. design note 4.7절의 "1단계: ROI+퍼센타일 교체"가 정확히 이 형태로 바로 착수 가능하다.

권장 순서: design note 4.7절과 동일 — (1) ROI+퍼센타일 외부 노드부터 (개선폭 대비 구현 비용 최소), (2) 여유 있으면 uzh-rpg/active_camera_exposure_controlkShim/kGradient 비교 평가.


5. 회전 오차 — "나무를 둘러싼 오차"가 맞는가, GPIO 절차가 가능한가

5.1 "나무 둘레 오차" 해석이 맞는가

메커니즘 이해는 맞다. 다만 나무에 한정된 현상이 아니라, 회전 중 시야에 들어오는 모든 정지된 근거리 물체에 공통으로 적용되는 일반적 현상이다.

design note 5.2절의 핵심은: 카메라 3대가 각자 다른 노출시간을 쓰면(AE가 독립적으로 도니까) 같은 트리거 엣지를 받아도 "유효 촬영 시각"(노출 중심)이 카메라마다 어긋난다. 회전 중에는 이 시간차(최대 1.42.45 ms)만큼 실제로는 카메라가 서로 다른 자세에서 그 장면을 봤는데, SLAM 파이프라인은 "같은 시각에 찍었다"고 가정하고 삼각측량/재투영을 한다 → 그 오차가 픽셀 단위로 나타난다.

이건 나무라는 물체 자체의 속성이 아니라 **"회전 중 시야에 잡힌 정지 물체 전반"**에 생기는 오차다. 다만 나무가 유독 눈에 잘 띄는 이유는 있다:

  1. 특징점 밀도 — 가지·잎·수피 텍스처는 ORB/FAST가 조밀하게 코너를 잡는 대상이라, 오차가 "몇 개 점이 튀는" 수준이 아니라 나무 윤곽 전체에 걸쳐 조직적으로 어긋나 보인다(design note 2.2절의 코너 검출기 논의와 같은 맥락).
  2. 근거리 물체일수록 오차가 커짐 — 3.1절 블러 공식의 f_px · v⊥ · t_exp / Z 항을 보면 알 수 있듯, 같은 각속도라도 물체까지의 거리 Z가 가까울수록(=시차/파랄랙스가 클수록) 타임스탬프 오차가 만들어내는 겉보기 픽셀 이동량이 커진다. 나무는 대개 배경보다 가까운 전경 물체라 이 효과가 두드러진다.

즉 "나무를 둘러싼 오차"라는 관찰은 정확하고, 일반화하면 "핸즈헬드로 몸을 돌릴 때 시야 안의 가까운 텍스처가 풍부한 정지 물체 주변에서 특히 두드러지는 재투영 오차"다.

5.2 5.3절 GPIO 절차가 지금 카메라에서 가능한가

하드웨어적으로는 가능성이 높다. 필요한 출력 라인이 물리적으로 비어 있다.

데이터시트 전기 사양:

Digital I/O: 6-pin P7 connector
  - opto-isolated input  × 1  (Line 0)
  - opto-isolated output × 1  (Line 1)
  - bi-directional non-isolated I/O × 1  (Line 2)

지금 시스템은 Line0만 쓰고 있다 (STM32 PWM → LINE0, 트리거 입력):

// hik_camera_node.cpp:157-158
MV_CC_SetEnumValue(camera_handle_, "TriggerMode", 1);
MV_CC_SetEnumValue(camera_handle_, "TriggerSource", MV_TRIGGER_SOURCE_LINE0);

Line1(전용 출력 라인)이 3대 카메라 모두 현재 미사용 상태로 남아 있다. design note 5.3-[A]가 요구하는 "ExposureActive/Strobe 신호를 오실로스코프로 측정" 절차에 쓸 출력 핀이 물리적으로는 준비되어 있다는 뜻이다.

다만 두 가지는 별도로 확인해야 한다 (코드나 데이터시트만으로는 100% 확답 불가):

  1. GenICam LineSourceExposureActive/Strobe가 옵션으로 있는가 — 이 SDK는 ADCBitDepth/PixelFormat/TriggerMode처럼 MV_CC_SetEnumValueByString()로 문자열 노드명을 직접 설정하는 방식이라, 헤더 파일만 봐서는 이 특정 라인 소스 옵션 목록이 드러나지 않는다. Hikvision MVS 프로그램으로 카메라 1대에 연결해서 Feature Tree → Digital IO Control → LineSelector=Line1LineMode(Output인지) → LineSource 드롭다운에 ExposureActive 또는 Strobe가 있는지 확인하는 게 가장 빠르다.
  2. 실제 배선(6-pin 케이블)이 Line1을 브레이크아웃했는가 — 트리거 입력용으로 Line0만 배선하고 Line1은 커넥터 안에 묶여있지 않을 수 있다. 물리적으로 핀이 나와 있는지 확인 필요.

두 조건이 맞으면 design note 5.3-[A]의 오실로스코프 절차(CH1=트리거 입력, CH2=Line1 출력)를 그대로 3대에 적용할 수 있고, 나아가 확인된 노드명을 알면 이미 코드에 있는 것과 동일한 패턴(MV_CC_SetEnumValueByString)으로 자동화도 가능하다.

권장 조치 순서: (1) MVS로 Line1의 LineSource 옵션 확인 → (2) 물리 배선 확인 → (3) 가능하면 오실로스코프로 t_latency 3대 실측 (design note 5.3-[A]) → (4) 5.3-[C]의 t_effective = t_trigger + t_latency + fExposureTime/2 보정 적용 (이때 fExposureTime은 3번 항목에서 다룬, 이미 SDK가 프레임마다 주는 실측 노출시간을 써야 함 — 설정값이 아니라).


요약

질문 결론
1. 노출 하한 100 µs 데이터시트상 15 µs까지 가능. 파라미터만 낮추면 됨 (코드 수정 불필요), 2.74 stop 추가 확보
2. 게인 상한 16 dB 정확함. 하드웨어 실측 상한 16.9 dB(스펙 17 dB)로, 이미 한계에 근접. 24 dB는 이 카메라로 불가능
3. 12-bit raw + 메타데이터 카메라/SDK는 지원하나, 드라이버가 RGB8Packed로 강제 변환하는 코드(enDstPixelType)가 있어 지금은 실제로 저장 안 됨 — 코드 수정 필요. 메타데이터(fExposureTime/fGain/nFrameNum)는 이미 SDK가 매 프레임 주고 있어 추가 활성화 없이 곧바로 로깅 가능. 12-bit raw 전환 시 용량은 오히려 현재보다 33~67% 감소 예상
4. 측광 지표 현재는 온보드 전체평균 AE라 하늘에 취약함이 확인됨. 카메라 자체에 AE 전용 ROI 기능은 안 보임 → exposure_auto:false + 외부 노드가 이미 지원되는 exposure_time/gain 파라미터를 실시간으로 되쓰는 방식으로 ROI+퍼센타일 측광 구현 가능 (드라이버 코드 변경 불필요)
5. 회전 재투영 오차 "나무 둘레 오차" 관찰은 맞음 — 나무 특정 문제가 아니라 회전 중 근거리 텍스처 물체 전반의 일반 현상. GPIO 절차는 Line1(opto-isolated 출력)이 물리적으로 비어 있어 가능성 높음, MVS로 LineSource 옵션과 배선만 확인하면 됨