6 Commits

Author SHA1 Message Date
Dongubak 0e98a96db2 자동 게인, ROI 제한 측광, 노출/게인 우선순위, 마스터-슬레이브 동기화에 이어
파라미터 조절 패널(hik_camera_panel) 패키지 추가

(이전 커밋 6eeb6a8 요약)
카메라 온보드 ExposureAuto/GainAuto가 항상 풀프레임을 측광해서 하늘이 있으면
바닥이 어두워지는 문제(camera_exposure_design_notes.md 4절)를 풀기 위해
hik_camera_node.cpp에 gain_auto(온보드 자동 게인), use_software_ae +
ae_roi_top_ratio(ROI 제한 소프트웨어 측광 — SDK에 측광 전용 ROI 노드가 없어
직접 구현), 노출 우선/게인 최후수단 순서, sync_role(master/slave) 기반
노출·게인 동기화를 추가했었다.

(이번 커밋 — 패널 기능 추가)
- hik_camera_node.cpp: dynamicParametersCallback()이 gain_auto_max_db,
  ae_roi_top_ratio, ae_target_percentile, ae_target_dn,
  ae_saturation_percentile, ae_saturation_dn, ae_step_gain을 런타임에도
  받아들이도록 확장했다. 기존엔 이 파라미터들이 시작 시에만 반영되고
  ros2 param set으로는 "Unknown parameter"로 거부되던 것을 고쳤다 — 패널에서
  슬라이더로 조절하려면 반드시 필요한 변경.
- 새 패키지 hik_camera_panel (ament_python, python_qt_binding 기반): cam1/cam2/
  cam3 탭으로 구성된 GUI. 노출/게인/ROI 등 파라미터를 슬라이더 또는 직접 숫자
  입력으로 실시간 조절할 수 있고, /<camera_name>/image를 구독해 ROI(측광 제외
  상단 비율) 경계선을 이미지 위에 오버레이로 보여준다. sync_role/
  sync_master_camera_ns는 노드 시작 시에만 반영되는 값이라 조회 전용으로 막아
  뒀다. 노출/노출상한/하한처럼 범위가 넓은(15us~100ms) 값은 슬라이더를 로그
  스케일로 매핑했다.

새 파라미터는 전부 기본값이 기존 동작을 유지하도록 해서 하위호환된다.
개발 PC(macOS)에는 ROS 2/python_qt_binding이 없어 문법 검사와 슬라이더↔값
변환 로직의 순수 파이썬 라운드트립 테스트만 돌려봤다 — 실제 rclpy 파라미터
서비스 호출, Qt 위젯 동작, 이미지 렌더링은 로봇 PC에서 검증 필요.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 00:04:03 +09:00
Dongubak 5a587b0149 cam2를 마스터, cam1/cam3를 슬레이브로 하는 노출·게인 동기화 설정, README 한글화
camera_params_cam1/2/3.yaml에 새로 추가된 sync_role/gain_auto/use_software_ae/
ae_* 파라미터를 실제로 채워넣었다. cam2(가운데)는 sync_role: "master"로 두고
게인 자동조절·소프트웨어 ROI 측광 관련 파라미터를 미리 선언해뒀다(조리개/초점이
아직 확정 전이라 gain_auto/use_software_ae 자체는 꺼둔 상태 — 값만 준비).
cam1/cam3는 sync_role: "slave" + sync_master_camera_ns: "cam2"만 추가했다
(슬레이브에서는 나머지 AE 파라미터가 무시되므로 넣지 않음). 이 상태만으로도
cam2의 실제 적용 노출/게인이 방송되어 3대가 항상 같은 값을 쓰게 된다.

README.md를 영어에서 한글로 전면 재작성하고, 마스터 off/on에 따라 슬레이브가
고정값을 쓰는지 실시간으로 추종하는지에 대한 동작 요약을 추가했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 00:03:42 +09:00
Dongubak 6eeb6a8297 자동 게인, ROI 제한 측광, 노출/게인 우선순위, 마스터-슬레이브 동기화 추가
카메라 온보드 ExposureAuto/GainAuto가 항상 풀프레임을 측광해서 하늘이
있으면 바닥이 어두워지는 문제(camera_exposure_design_notes.md 4절)를
풀기 위해 hik_camera_node.cpp에 4가지 기능을 추가했다.

- gain_auto: 기존에는 GainAuto가 코드에서 항상 강제 Off였는데, 이제
  온보드 Continuous 자동 게인을 켤 수 있음
- use_software_ae + ae_roi_top_ratio: SDK에 측광 전용 ROI 노드가 없어
  (캡처 크롭용 AOI만 있음, 확인 완료) 온보드 auto를 끄고 프레임 하단
  일부만 퍼센타일로 측광해 ExposureTime/Gain을 직접 계산해 적용
- 우선순위: 밝게 할 땐 노출을 상한까지 먼저 늘리고 그다음에만 게인
  사용, 어둡게 할 땐 반대로 게인을 먼저 줄이는 순서를 명시적으로 구현
  (게인은 노이즈 비용이 있어 항상 마지막 수단)
- sync_role(master/slave) + ae_exposure_time_us/ae_gain_db 토픽: 가운데
  카메라를 master로 지정하면 그 카메라의 실제 적용 노출/게인(SDK가
  매 프레임 주는 값)을 나머지 slave 카메라들이 그대로 따라가도록 함

새 파라미터는 전부 기본값이 기존 동작을 유지하도록 해서 기존 배포
설정과 하위호환된다. package.xml에 std_msgs 의존성 추가, README에
전체 파라미터 설명 반영. 로봇 PC에서 colcon build 검증 필요 — 특히
AutoGainLowerLimit/AutoGainUpperLimit 노드명은 기존 Exposure 쪽과
같은 명명 규칙일 것으로 가정하고 썼을 뿐 실기기로 확인된 값은 아님.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 23:32:29 +09:00
Dongubak 539a97f3b4 카메라 3대 노출/게인 수동 고정 및 화이트밸런스 오프로 재설정
1차 현장 테스트(f/5.6, Auto Exposure/Gain) 결과 실내에서 이미지가 밝게
나오는 문제를 확인, 조리개·초점 조절 작업을 위해 3대(cam1/2/3) 모두
exposure_auto를 false로 바꾸고 노출/게인을 수동 고정값으로 전환했다.
화이트밸런스도 조리개/초점 조절 중 색 보정이 판단을 방해하지 않도록
balance_ratio R/G/B를 1024(중립)로 꺼 두었다. 기존 cam1-cam2 겹침
기준 값은 주석으로 남겨 나중에 3대 겹침 매칭을 다시 잡을 때 참조한다.

.gitignore에 .obsidian/ 추가 (doc/ 폴더를 Obsidian vault로 여는 로컬
설정 파일이 저장소에 섞여 들어가는 것을 방지).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 23:32:09 +09:00
admin fcbfb987fa Merge pull request 'Add camera exposure design notes and Q&A grounded in actual hardware specs' (#1) from docs/camera-exposure-review into main
Reviewed-on: http://210.113.91.65:3000/gardentech/fori_camera_ws/pulls/1
2026-08-13 22:30:17 +09:00
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
22 changed files with 2426 additions and 88 deletions
+1
View File
@@ -6,3 +6,4 @@ log/
__pycache__/ __pycache__/
*.pyc *.pyc
.vscode/ .vscode/
.obsidian/
+101
View File
@@ -0,0 +1,101 @@
# 2026-08-15 1차 현장 테스트 — 노출/게인/조리개 재조정
> `camera_exposure_design_notes.md`(설계노트), `question_1.md`/`answer_1.md`(하드웨어 실측 확인) 후속. 1차 테스트 결과를 반영해 조리개 결정 절차 방향을 바꾼다.
---
## 1. 테스트 조건 및 관찰
| 항목 | 값 |
|---|---|
| 조리개 | f/5.6 |
| 노출 | Auto Exposure |
| 게인 | Auto Gain |
| 도구 | MVS 소프트웨어 |
**관찰:** 중간 밝기의 실내에서 카메라 이미지가 조금 밝은 경향. 자동 노출 알고리즘(전체 프레임 평균 밝기 기준 `AutoTargetBrightness`, `camera_exposure_design_notes.md` 4.1절)이 원인으로 추정됨.
**결정:**
1. 고정 노출값 + 고정 게인값을 먼저 정하고, 그 상태에서 적절한 조리개 값을 찾는 절차로 전환한다.
2. **지하주차장 등 매우 어두운 곳의 스캔은 포기**하고, 밝은 야외(직사광)를 확실히 커버하는 쪽을 우선한다. 이에 따라 조리개를 f/5.6보다 더 조이는 방향을 검토 중.
---
## 2. 조이기 전에 먼저 확인할 것 — 노출 하한 쪽 여유가 훨씬 크다
"밝은 야외를 커버하려면 더 조여야 한다"는 방향 자체는 맞지만, 계산해보면 **조리개보다 노출 하한 쪽에 훨씬 큰 미사용 여유가 남아 있다.**
```
f/5.6 → f/8 (한 단 더 조임):
Δstop = 2·log2(8/5.6) = 1.03 stop 확보
대신 Airy disk 7.5µm(2.17px) → 10.7µm(3.1px)로 회절 블러 악화 (2절 참조)
현재 exposure_auto_min = 100 µs
카메라 실제 하한(MV-CS016-10UC 데이터시트, Standard exposure mode) = 15 µs:
Δstop = log2(100/15) = 2.74 stop 확보, 해상력 손실 없음
```
노출 하한을 100 µs에서 15 µs 근방으로 낮추는 것만으로 f/8로 조이는 것보다 **더 큰 stop을 해상력 손실 없이** 얻는다. **조리개를 더 조이는 건 이 여유를 다 쓰고도 모자랄 때 쓰는 마지막 카드로 남겨둔다.** 다음 테스트 전에 `exposure_auto_min`이 실제로 몇으로 설정되어 있는지부터 확인.
---
## 3. 고정 t / gain 값을 정하는 방법 — "밝기 맞추기"가 아니라 독립된 물리 제약으로
지금 겪은 문제(AE가 조금 밝게 나옴)의 근본 원인은 `AutoTargetBrightness`가 높게 잡혀 있거나 전체 평균 측광이 밝은 벽/조명에 끌려가는 것이다. 이 문제를 "고정 노출/게인 값을 스캔해서 밝기가 맞는 값 찾기"로 풀면, 그 값은 **그 장면 하나에만 맞는 우연의 값**이 된다. 조명이 다른 장면으로 가면 다시 틀어진다.
올바른 순서는 **t와 gain을 서로 다른 물리적 제약으로 각각 독립적으로 결정**하고, **밝기 맞추기는 오로지 조리개의 몫**으로 넘기는 것 (설계노트 3절).
### 3.1 t (노출시간) — 블러 예산으로 결정
```
t_blur_max = 블러목표[px] / (f_px · ω)
f_px ≈ 1159 (VM0420MP5 4mm ÷ 3.45µm, 실측 캘리브레이션 fx≈1200과 일치 — 설계노트 3.1절)
```
IMU 로그에서 회전 각속도 95퍼센타일을 뽑아 대입한다. 아직 실측 전이면 기존 참고값(ω=1.5 rad/s·블러 1px → 0.58 ms, ω=1.0 rad/s·블러 1.5px → 1.29 ms) 중 하나로 t≈0.8~1 ms를 잠정 상한으로 잡고 시작, 실측 IMU 데이터가 나오면 재계산한다. **이 값은 밝기가 아니라 블러로 정해지므로 장면이 바뀌어도 바뀌면 안 된다.**
### 3.2 gain — 노이즈/특징점 품질 스윕으로 결정
같은 장면을 gain 0 / 6 / 12 / 16.9 dB(이 카메라 실측 상한)로 바꿔가며 촬영 → ORB/FAST 특징점 개수와 매칭 인라이어 비율이 급격히 꺾이는 지점 직전을 `g_work`로 잡는다.
**단, 어두운 곳을 포기하기로 했으니 이번엔 낮은 쪽을 선택한다.** 원래 설계노트의 g_work≈12dB 권장은 지하주차장까지 커버하려고 게인을 최대한 끌어쓰려던 전제였다. 그 전제를 버렸으니, 밝은/중간 밝기 장면에서 노이즈를 최소화하는 **낮은 gain(0~6 dB)** 이 지금 목표에 더 맞는다.
---
## 4. 조리개 결정 절차 — "밝은 쪽" 기준으로 전환 (설계노트 3.4절을 주 절차로 승격)
원래 설계노트는 "가장 어두운 곳" 기준으로 조리개를 여는 절차였는데, 어두운 곳을 포기했으니 순서를 뒤집는다.
1. MVS에서 수동 모드: `ExposureTime = t_blur_max`(3.1절), `Gain = g_work`(3.2절, 낮게)로 고정
2. **가장 밝을 것으로 예상되는 실외(직사광)**로 이동
3. MVS 히스토그램/통계 도구로 ROI(하늘 제외) 확인: **98퍼센타일이 포화 근처(8-bit 기준 DN 245 이상)에 안 걸리는지** 체크
4. 걸리면 → 먼저 **2절의 노출 하한(15 µs 근방)이 실제로 적용되어 있는지부터 확인**. 그래도 부족하면 그때 조리개를 한 단 조이고 3번부터 반복
5. 이 조리개가 정해지면, 처음 테스트한 "중간 밝기 실내" 장면으로 돌아가 같은 t/gain에서 median DN이 너무 어둡지 않은지 확인 (설계노트 3.3절 DN 100~120 목표)
4번에서 조리개를 계속 조여야 하는 상황이 반복되면, 그건 원래 문제(f/8~f/11의 회절 손실, 설계노트 2절)로 되돌아가는 신호다. 2.4절의 ORB 특징점-vs-조리개 스윕으로 실제 손실을 확인하며 진행할 것.
---
## 5. 배포 시엔 완전 고정보다 "좁게 bound된 Auto"를 권장
t/gain을 계산·스윕으로 정하는 것은 맞지만, 실제 촬영에서 정말 하나의 상수로 완전 고정하면 밝은 쪽 여유(15µs까지 내려가는 6~7 stop)를 못 쓴다. 대신:
- `exposure_auto: true` 유지, `exposure_auto_min ≈ 15~30 µs`, `exposure_auto_max = t_blur_max`(3.1절 값)로 **범위를 좁게 bound**
- `gain`은 낮은 값으로 고정하거나 아주 좁은 범위(예: 0~6 dB)로 bound
- `exposure_auto_target_brightness`를 지금보다 낮춰서 "조금 밝은 경향" 자체도 같이 완화
이렇게 하면 밝은 야외에서는 노출이 자동으로 15 µs 근방까지 짧아져 포화를 피하고, 블러 상한은 여전히 지켜지며, 조리개는 f/5.6에서 더 조이지 않아도 될 가능성이 높다.
덤으로, 노출시간 변동폭을 이렇게 좁게 묶으면 설계노트 5.4절이 지적한 "카메라 3대가 서로 다른 노출시간 → 유효시각 어긋남" 문제도 자연히 줄어든다.
---
## 요약
| 질문 | 결론 |
|---|---|
| AE가 실내에서 밝게 나오는 이유 | `AutoTargetBrightness` + 전체평균 측광 (설계노트 4.1절과 동일 원인) |
| 밝은 야외 커버하려고 지금 조여야 하는가 | 조이기 전에 `exposure_auto_min`(현재 100µs)부터 15µs 근방으로 낮출 것 — 조리개 1단(1.03 stop, 해상력 손실 있음)보다 노출 하한(2.74 stop, 손실 없음)이 더 큰 여유 |
| 고정 t/gain을 어떻게 정하는가 | t는 블러 예산(3.1절)으로, gain은 특징점 품질 스윕(3.2절)으로 — 밝기로 맞추지 않음. 어두운 곳 포기했으니 gain은 낮게(0~6dB) |
| 조리개는 어떻게 정하는가 | 4절 — 이제는 "가장 밝은 곳에서 포화 안 되는지"가 1순위 기준, "가장 어두운 곳"은 검증만 |
| 실제 배포 설정 | 완전 고정보다 좁게 bound된 Auto Exposure/Gain 권장 (5절) |
+94
View File
@@ -0,0 +1,94 @@
# 2026-08-16 코드 변경 — ROI 측광 / 자동 게인 / 우선순위 / 마스터-슬레이브 동기화
> `hik_camera_node.cpp`에 실제 기능을 추가했다. 아래는 무엇을, 왜, 어떻게 바꿨는지와 사용법.
> 관련 배경: `2026-08-15_test1_notes.md`, `camera_exposure_design_notes.md` 4절/5.4절.
---
## 1. 요청 사항 4가지와 대응
| 요청 | 결론 |
|---|---|
| 자동 게인 도입 | `gain_auto` 파라미터 신규 추가 (기존엔 `GainAuto`가 항상 강제 Off였음) |
| AE 측광 ROI를 화면 중간~맨 아래로 제한 | **가능. 단, 카메라 온보드 기능이 아니라 소프트웨어로 구현** (아래 2절 이유 참조) |
| 노출/게인 자동 조절 우선순위 지정 | `use_software_ae` 루프에서 명시적으로 구현 (노출 먼저, 게인은 최후 수단) |
| 가운데 카메라가 나머지 노출/게인을 결정 | `sync_role`(master/slave) + 토픽 2개로 구현 |
---
## 2. 왜 ROI 측광이 카메라 온보드로는 안 되는가
`hikSDK/include/MvCameraControl.h`, `CameraParams.h`를 다시 검색했다. AE 전용 측광 ROI(흔히 "AutoFunctionAOI"류)에 해당하는 노드/함수는 없고, 있는 건:
```
MV_CC_GetAOIoffsetX/Y, MV_CC_SetAOIoffsetX/Y ← 캡처 자체를 크롭하는 AOI (측광 전용 아님)
GainAuto 관련 전용 C 함수는 아예 없음 (ExposureAuto와 동일하게 문자열 노드로만 설정 가능)
```
즉 이미지를 자르지 않고 "측광 계산에만" 특정 영역을 쓰는 카메라 자체 기능이 없다. 그래서 온보드 `ExposureAuto`/`GainAuto`를 끄고, 소프트웨어에서 직접 퍼센타일을 계산해 `ExposureTime`/`Gain`을 쓰는 방식(`use_software_ae`)으로 구현했다.
---
## 3. 새 파라미터
### 게인 (기존 `gain`은 유지, 아래 신규)
| 파라미터 | 기본값 | 의미 |
|---|---|---|
| `gain_auto` | `false` | true면 게인 자동 조절 (온보드 Continuous 또는 소프트웨어 루프) |
| `gain_auto_max_db` | `12.0` | 자동 게인 상한. 하드웨어 실측 상한(~16.9dB, `answer_1.md` 2절)보다 낮게 잡아 노이즈 제한 |
### 소프트웨어 AE/AG (ROI 측광 + 우선순위)
| 파라미터 | 기본값 | 의미 |
|---|---|---|
| `use_software_ae` | `false` | true면 온보드 auto 대신 아래 방식으로 직접 계산 |
| `ae_roi_top_ratio` | `0.0` | 측광에서 제외할 상단 비율. `0.5` = 하단 절반만 측광(하늘 제외) |
| `ae_target_percentile` / `ae_target_dn` | `70.0` / `130` | ROI 내 목표 퍼센타일이 이 DN이 되도록 조절 (설계노트 4.2절과 동일 사상) |
| `ae_saturation_percentile` / `ae_saturation_dn` | `98.0` / `245` | 이 퍼센타일이 이 DN을 넘지 않도록 하는 하드 제약 — 목표 미달이어도 이게 우선 |
| `ae_step_gain` | `0.5` | 프레임당 보정 비율(댐핑). 크면 빨리 수렴하되 진동 위험 |
**우선순위 로직** (`runSoftwareAeStep()`): 밝게 해야 할 때는 **노출을 `exposure_auto_max`까지 먼저** 늘리고, 그래도 모자라면 게인을 `gain_auto_max_db`까지 씀. 어둡게 해야 할 때는 반대로 **게인을 먼저** 줄이고, 그래도 남으면 노출을 줄임. 게인은 노이즈 비용이 있으므로 항상 마지막 수단.
### 마스터-슬레이브 동기화
| 파라미터 | 기본값 | 의미 |
|---|---|---|
| `sync_role` | `"independent"` | `independent`(기존 동작) / `master` / `slave` |
| `sync_master_camera_ns` | `""` | `sync_role=slave`일 때 구독할 마스터의 `camera_name` (예: `"cam2"`) |
동작 방식: 모든 카메라가 `<camera_name>/ae_exposure_time_us`, `<camera_name>/ae_gain_db` 토픽을 항상 발행한다(관찰용, role 무관). `master`는 이 값을 SDK가 매 프레임 주는 **실제 적용값**(`MV_FRAME_OUT_INFO_EX::fExposureTime`/`fGain` — 이미 `answer_1.md` 3절에서 "이미 있는데 안 쓰고 있다"고 지적했던 그 필드)으로 채워 발행하므로, 온보드 auto든 소프트웨어 AE든 수동 고정이든 어떤 모드든 그대로 방송된다. `slave`는 자기 auto 로직을 전부 끄고, 구독한 마스터 값을 그대로 `ExposureTime`/`Gain`에 적용한다.
**사용 예 (cam2가 마스터, cam1/cam3가 슬레이브):**
```yaml
# camera_params_cam2.yaml
sync_role: "master"
# camera_params_cam1.yaml, camera_params_cam3.yaml
sync_role: "slave"
sync_master_camera_ns: "cam2"
```
---
## 4. 알아두어야 할 제약
1. **`AutoGainLowerLimit`/`AutoGainUpperLimit` 노드명은 검증되지 않음.** `AutoExposureTimeLowerLimit`/`UpperLimit`과 같은 명명 규칙일 거라 가정하고 그대로 썼다 (`applyGainMode()`). 실패하면 `RCLCPP_WARN`으로 바로 로그에 뜨니, 뜨면 MVS Feature Tree에서 실제 노드명을 확인해서 고쳐야 한다. (`gain_auto`+`use_software_ae=false` 조합, 즉 온보드 auto gain을 쓸 때만 해당 — `use_software_ae=true`면 이 노드를 안 쓰므로 무관.)
2. **기본값은 전부 기존 동작을 그대로 유지한다.** `gain_auto:false`, `use_software_ae:false`, `sync_role:"independent"` — 기존 3대 yaml(`camera_params_cam1/2/3.yaml`)은 코드 수정 후에도 아무 변경 없이 그대로 동작한다. 새 기능을 쓰려면 yaml에 위 파라미터를 명시적으로 추가해야 한다 (이번에 yaml 파일 자체는 건드리지 않았다 — 실제 배포 설정은 테스트해보고 정할 문제라 판단).
3. **소프트웨어 AE 루프는 매 프레임 이미지에서 4픽셀 간격으로 서브샘플링해 밝기 퍼센타일을 계산**한다 (`runSoftwareAeStep()`). 1440×1080 기준 약 9.7만 샘플, 정렬 비용은 10Hz에서 무시할 만한 수준.
4. **동기화는 ROS 파라미터/토픽 레이어를 거치므로 완전한 프레임 단위 lockstep은 아니다.** 마스터가 결정한 값이 슬레이브에 반영되기까지 최소 한 토픽 왕복(수 ms~한 프레임 이내, 10Hz 트리거 기준) 지연이 있다. 카메라 3대가 완전히 다른 방향(예: 하나는 역광, 하나는 그늘)을 보고 있으면 슬레이브가 부적절하게 노출될 수 있음 — 실측으로 확인 필요 (설계노트 5.4절의 Master-Slave 단점과 동일한 근본 제약).
---
## 5. 별도로 코드 변경이 필요 없던 것 — 화이트밸런스
같은 대화에서 논의된 화이트밸런스 "끄기"는 코드 변경 없이 이미 가능하다: `balance_white_auto:false` + `balance_ratio_red/green/blue: 1024/1024/1024`(중립/1x 게인)로 설정하면 됨. cam2-cam3 겹침 매칭은 아직 실측/적용 전 — cam1-cam2와 같은 방식으로 겹침 장면에서 Continuous AWB 수렴값을 읽어(`captureLoop()`가 3초마다 로그로 이미 출력) 3대 평균을 내는 절차가 남아 있음 (코드 변경 사항 아님, 데이터 수집 후 yaml 값만 갱신하면 됨).
---
## 6. 빌드 검증에 대한 한계
이 변경은 개발 PC(macOS, ROS 2/colcon 미설치)에서 작성되어 **실제 `colcon build`로 컴파일 검증을 하지 못했다.** 코드를 전체 재검토해 문법·타입·초기화 순서 오류를 잡았지만(초기 구현에서 `soft_ae_exposure_us_`/`soft_ae_gain_db_` 초기화 순서 버그 1건 발견해 수정함), 로봇 PC에서 `colcon build --symlink-install` 후 다음을 반드시 확인할 것:
- 빌드 자체가 통과하는지 (특히 `AutoGainLowerLimit`/`AutoGainUpperLimit` 같은 문자열 노드는 컴파일 타임에 검증 안 됨 — 런타임 WARN 로그로만 확인 가능)
- `use_software_ae:true`로 켰을 때 `ros2 topic echo /cam2/ae_exposure_time_us` 등으로 실제 값이 그럴듯하게 움직이는지
- `sync_role` master/slave 조합에서 슬레이브가 마스터 값을 잘 따라가는지
+269
View File
@@ -0,0 +1,269 @@
# 질문지 답변
> `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 여유 증가
```
코드상 이 값의 실제 상한/하한은 카메라에서 직접 쿼리한다:
```cpp
// 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`에 이미 실측으로 남겨둔 주석과 정확히 일치한다:
```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대 모두:
```yaml
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`를 보면 목적 포맷이 초기화 시점에 고정되어 있다:
```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; // ← 항상 이걸로 변환
```
```cpp
// 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`):
```c
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를 그대로 쓴다:
```cpp
// 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를 끄고 외부 루프로 대체하는 게 맞는 방향이고, 이 드라이버는 그걸 위한 훅을 이미 갖고 있다.**
```cpp
// 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_control``kShim`/`kGradient` 비교 평가.
---
## 5. 회전 오차 — "나무를 둘러싼 오차"가 맞는가, GPIO 절차가 가능한가
### 5.1 "나무 둘레 오차" 해석이 맞는가
**메커니즘 이해는 맞다. 다만 나무에 한정된 현상이 아니라, 회전 중 시야에 들어오는 모든 정지된 근거리 물체에 공통으로 적용되는 일반적 현상이다.**
design note 5.2절의 핵심은: 카메라 3대가 각자 다른 노출시간을 쓰면(AE가 독립적으로 도니까) 같은 트리거 엣지를 받아도 "유효 촬영 시각"(노출 중심)이 카메라마다 어긋난다. 회전 중에는 이 시간차(최대 ~1.4~2.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, 트리거 입력):
```cpp
// 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 `LineSource`에 `ExposureActive`/`Strobe`가 옵션으로 있는가** — 이 SDK는 `ADCBitDepth`/`PixelFormat`/`TriggerMode`처럼 `MV_CC_SetEnumValueByString()`로 문자열 노드명을 직접 설정하는 방식이라, 헤더 파일만 봐서는 이 특정 라인 소스 옵션 목록이 드러나지 않는다. Hikvision MVS 프로그램으로 카메라 1대에 연결해서 Feature Tree → Digital IO Control → `LineSelector=Line1``LineMode`(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` 옵션과 배선만 확인하면 됨 |
+773
View File
@@ -0,0 +1,773 @@
# 멀티카메라 + LiDAR SLAM 노출 설계 노트
> 대상 시스템: 하드웨어 트리거 동기 카메라 3대 + LiDAR, 후처리(offline) SLAM
> 목적: 실내/실외 혼재 환경에서 포화(하얗게 뜸) 없이 SLAM 프론트엔드가 쓸 만한 이미지를 확보
---
## 0. 요약 (먼저 읽을 것)
| 항목 | 현재 | 권장 |
|---|---|---|
| 조리개 | f/8 ~ f/11 | **f/4 (또는 f/2.8)** — 실장착 렌즈 `VM0420MP5` 최대개방 F2.0 기준 1~2 stop 닫은 값, 회절 한계(f/5.1) 이내 |
| 노출 하한 | 100 µs | **15 µs** (`MV-CS016-10UC` 데이터시트 Standard exposure mode 하한 — 개체별 실측 권장) |
| 노출 상한 | 5000 µs | **블러 계산으로 결정 (우리 렌즈 4mm 기준 실측하면 0.6~1.3 ms, "보통 2~3 ms"는 광각 렌즈 가정 — 3.1절 참조)** |
| 게인 상한 | 16 dB | **16.9 dB가 하드웨어 실측 상한(더 못 엶)**, 실사용 목표는 12 dB |
| 측광 | 전체 평균 밝기 | **ROI + 퍼센타일**, 이후 gradient 기반 |
| 타임스탬프 | 트리거 시각 | **트리거 + 지연 + 노출시간/2** |
| 저장 | 8-bit | **12-bit raw + 프레임별 메타데이터** |
핵심 진단 세 가지:
1. **f/8~f/11은 회절 한계를 2~3배 넘어섰습니다.** 빛도 잃고 해상력도 잃는 이중 손해입니다.
2. **조리개를 "가장 밝을 때" 기준으로 잡으면 실내를 물리적으로 커버할 수 없습니다.** 뒤의 EV 계산으로 확인 가능합니다.
3. **게인 실측 상한은 24 dB가 아니라 16.9 dB입니다** (카메라 데이터시트 및 `camera_params_cam1/2/3.yaml` 실측 주석으로 확인, `MV-CS016-10UC` 스펙상 0~17 dB). 아래 1.4절/3.2절/7.2절의 "24 dB" 시나리오는 이 카메라에서 재현 불가능하며, 16.9 dB 기준으로 재계산했습니다.
---
## 1. Stop(스톱) 계산법
### 1.1 정의
**1 stop = 센서에 도달하는 빛의 양이 정확히 2배 또는 절반.**
모든 노출 파라미터를 stop이라는 하나의 단위로 환산하면 서로 직접 더하고 뺄 수 있습니다.
```
stop = log2(빛의 양 비율)
```
### 1.2 파라미터별 환산식
**노출 시간 (exposure time)** — 빛의 양에 정비례
```
Δstop = log2(t2 / t1)
```
- 100 µs → 5000 µs : `log2(5000/100) = log2(50) = 5.64 stop`
**게인 (gain, dB)** — 머신비전 카메라는 `dB = 20·log10(선형배수)` 규약을 씁니다
```
선형배수 = 10^(dB / 20)
Δstop = dB / 6.02
```
- 16 dB : `16 / 6.02 = 2.66 stop` (선형 6.3배)
- 24 dB : `24 / 6.02 = 3.99 stop` (선형 15.8배)
- 외우기: **6 dB ≈ 1 stop**
**조리개 (f-number, N)** — 빛의 양이 `1/N²`에 비례
```
Δstop = 2 · log2(N_old / N_new) ← 양수면 밝아짐
```
- f/11 → f/4 : `2·log2(11/4) = 2·1.459 = 2.92 stop 밝아짐`
- f/8 → f/4 : `2·log2(8/4) = 2.00 stop 밝아짐`
- f/8 → f/11 : `2·log2(8/11) = -0.92 stop` (약 1 stop 어두워짐)
표준 조리개 계열은 √2배씩 증가하며 각 단계가 정확히 1 stop입니다:
```
f/1.4 f/2 f/2.8 f/4 f/5.6 f/8 f/11 f/16
```
**조도 (lux)** — 그대로 비율의 log2
- 직사광 100,000 lux vs 실내 300 lux : `log2(100000/300) = 8.4 stop`
### 1.3 현재 시스템의 stop 예산
```
노출: 100 → 5000 µs = 5.64 stop
게인: 0 → 16 dB = 2.66 stop
------------------------------------
합계 (조리개 고정 시) = 8.30 stop (약 315배)
```
실외 직사광 ↔ 일반 실내 차이가 8~9 stop이므로, **여유가 사실상 0**입니다. 실내에서 노출 상한과 게인 상한을 동시에 최대로 써야 겨우 적정 노출이 되고, 이는 모션 블러와 노이즈가 동시에 최악인 지점입니다.
### 1.4 EV(Exposure Value)로 커버 범위 확인하기
Stop보다 실전적인 도구입니다. 장면 밝기를 `EV100`이라는 절대 숫자로 표현합니다.
**적정 노출 조건식:**
```
log2(N² / t) = EV_scene + g
N = f-number
t = 노출시간 [초]
g = 게인 [stop] = dB / 6.02
```
이걸 뒤집으면, **주어진 세팅이 커버할 수 있는 장면 밝기**가 나옵니다:
```
EV_scene = log2(N² / t) - g
```
**참고 EV100 값:**
| 장면 | EV100 |
|---|---|
| 눈밭/수면 반사 직사광 | 16 |
| 맑은 날 직사광 | 15 |
| 흐린 날 실외 | 12~13 |
| 건물 그늘 / 해질녘 | 10~11 |
| 밝은 실내(사무실, 창가) | 7~8 |
| 어두운 실내(창고, 복도) | 5~6 |
| 지하주차장 | 3~5 |
**현재 세팅(f/8, 100~5000 µs, 0~16 dB) 커버 범위:**
```
가장 밝은 쪽: t=100µs, g=0 → EV = log2(64/0.0001) - 0 = 19.29
가장 어두운쪽: t=5000µs, g=16dB → EV = log2(64/0.005) - 2.66 = 10.98
→ 커버 범위: EV 10.98 ~ 19.29
```
**해석:** 위쪽 EV 19.3은 지구상에 거의 없는 밝기입니다(직사광이 15). 즉 **3~4 stop을 아무 쓸모없는 밝은 쪽에 낭비**하고 있고, 아래쪽은 EV 11에서 끊겨서 **실내(EV 5~8)에 4~6 stop 모자랍니다.**
**f/4로 열었을 때:**
```
밝은 쪽: log2(16/0.0001) = 17.29
어두운쪽: log2(16/0.005) - 2.66 = 8.98
→ EV 8.98 ~ 17.29
```
**f/4 + 게인 상한 16.9 dB (실측 하드웨어 상한 — 24 dB는 이 카메라에서 불가능):**
```
g = 16.9 / 6.02 = 2.81 stop
어두운쪽: log2(16/0.005) - 2.81 = 8.84
→ EV 8.84 ~ 17.29 ← 밝은 실내(EV 7~8) 상단에 겨우 걸침. 직사광은 커버하지만
어두운 실내(EV 5~6)·지하주차장(EV 3~5)은 이 조합만으론 못 미침
```
**f/2.8 + 게인 상한 16.9 dB + 노출 상한 3 ms(블러 고려):**
```
g = 16.9 / 6.02 = 2.81 stop
밝은 쪽: log2(7.84/0.0001) = 16.26
어두운쪽: log2(7.84/0.003) - 2.81 = 8.55
→ EV 8.55 ~ 16.26
```
**정정:** 원래 초안은 게인 상한을 24 dB(3.99 stop)로 가정해 "f/4+24dB → EV 7.65부터 커버", "f/2.8+24dB+3ms → EV 7.71까지 커버"라고 결론 내렸었습니다. 하지만 이 카메라의 실측 게인 상한은 16.9 dB(2.81 stop)로 확인되어(0번 요약 및 3.2절 참조), 위 두 시나리오 모두 **다크사이드 EV가 약 1.2 stop씩 나빠집니다.** 즉 조리개를 열고 게인을 최대로 써도 "밝은 실내"(EV 7~8) 하단조차 안정적으로 커버하지 못하고, "어두운 실내"(EV 5~6)는 이 조합으로는 원천적으로 불가능합니다. 어두운 쪽 stop을 더 벌려면 게인이 아니라 **더 밝은 렌즈(더 낮은 F No.) 또는 더 긴 노출 상한(3.1절의 실제 블러 한계 재계산 필요 — 아래 참조)** 쪽에서 찾아야 합니다.
이 계산을 스프레드시트나 짧은 파이썬 스크립트로 만들어 두면, 렌즈를 바꾸거나 상한을 조정할 때마다 즉시 검증할 수 있습니다.
```python
import math
def coverage(N, t_min_us, t_max_us, gain_max_db):
g = gain_max_db / 6.02
ev_bright = math.log2(N**2 / (t_min_us * 1e-6))
ev_dark = math.log2(N**2 / (t_max_us * 1e-6)) - g
return ev_dark, ev_bright
print(coverage(8.0, 100, 5000, 16)) # 현재
print(coverage(4.0, 100, 3000, 16.9)) # 권장 — 게인은 16.9 dB가 이 카메라의 실측 하드웨어 상한(24 dB 불가)
```
---
## 2. 렌즈 스위트 스팟 (Sweet Spot)
### 2.1 왜 존재하는가
렌즈의 선명도(MTF)를 조리개에 대해 그리면 **가운데가 가장 좋은 U자 뒤집힌 형태**가 됩니다. 양쪽 끝이 나빠지는 이유가 서로 다릅니다.
**조리개를 열었을 때(f/1.4~f/2) 나빠지는 이유 — 수차(aberration)**
- 구면수차, 코마, 비점수차 → 렌즈 주변부 광선이 한 점에 모이지 않음
- 비네팅(주변부 광량 저하) 심함 → **photometric 일관성에 직접 악영향**
- 상면만곡(field curvature) → 화면 가장자리 초점 어긋남
- 피사계심도 얕음 → 근거리/원거리 동시에 못 잡음
> **실장착 렌즈 기준:** ZLKC `VM0420MP5`(4mm, C-mount, 수동 아이리스)는 최대개방이 **F2.0**이 한계입니다(`resource/VM0420MP5 en.pdf`). f/1.4 영역은 이 렌즈로는 애초에 시험 불가 — 수차가 가장 심한 극단은 f/2.0이고, 그보다 열 수 없다는 점에서 오히려 안전한 쪽입니다.
**조리개를 조였을 때(f/8~f/16) 나빠지는 이유 — 회절(diffraction)**
- 이건 렌즈 품질과 무관한 **물리 법칙**입니다. 좋은 렌즈를 사도 해결 안 됩니다.
- 조리개 구멍이 작아질수록 빛이 회절해 점광원이 Airy disk로 퍼집니다.
```
Airy disk 지름 = 2.44 · λ · N (λ ≈ 0.55 µm, 가시광 중심)
```
| 조리개 | Airy disk 지름 |
|---|---|
| f/2.8 | 3.8 µm |
| f/4 | 5.4 µm |
| f/5.6 | 7.5 µm |
| **f/8** | **10.7 µm** |
| **f/11** | **14.7 µm** |
| f/16 | 21.5 µm |
### 2.2 우리 시스템에 적용
픽셀 피치 3.45 µm은 이제 가정이 아니라 확인된 값입니다 — 실장착 카메라 `MV-CS016-10UC`가 정확히 Sony IMX273(3.45 µm)을 씁니다(`resource/MVCS01610UMUC ... datasheet.pdf`). 아래 계산이 바로 우리 카메라 값입니다:
- **f/8** → Airy 10.7 µm = **픽셀 3.1개에 걸쳐 번짐**
- **f/11** → Airy 14.7 µm = **픽셀 4.3개에 걸쳐 번짐**
즉 현재 세팅은 **하드웨어적으로 이미지를 3~4픽셀 블러 처리한 것과 같습니다.** 코너 검출기(FAST/ORB/Harris)는 로컬 gradient 피크에 의존하므로, 이 정도 번짐이면 검출되는 특징점 수와 위치 반복성(repeatability)이 눈에 띄게 나빠집니다.
**회절 한계 조리개 (Airy 지름 = 픽셀 2개 기준):**
```
N_limit = 2·p / (2.44 · λ)
p = 3.45 µm → N_limit = 6.9 / 1.342 ≈ f/5.1 ← 우리 카메라(MV-CS016-10UC, IMX273)가 정확히 이 값
p = 2.74 µm → N_limit ≈ f/4.1
p = 5.86 µm → N_limit ≈ f/8.7
```
**→ 우리 시스템의 조리개 상한선은 f/5.1입니다.** f/4 권장안은 이 한계 안쪽이라 문제 없고, f/5.6은 살짝 벗어나므로 f/4 쪽이 더 안전합니다.
### 2.3 대부분 렌즈의 스위트 스팟
**최대개방에서 2~3 stop 조인 지점**, 실무적으로 **f/4 ~ f/5.6**이 대부분입니다.
우리 렌즈(`VM0420MP5`, 최대개방 F2.0)에 그대로 대입하면 2~3 stop 조인 지점이 정확히 **f/4 ~ f/5.6**이라, 이 일반론이 실제 렌즈 스펙과 잘 맞아떨어집니다. 다만 2.2절에서 구한 회절 한계(f/5.1)를 넘지 않으려면 **f/4 쪽으로 붙이는 게 안전**합니다 — f/5.6은 회절 한계를 살짝 넘습니다.
### 2.4 그래도 실측으로 확인할 것
이론은 이론이고, 저가 광각 렌즈는 개방에서 정말 형편없을 수 있습니다. 다음 절차로 30분 만에 확인 가능합니다:
1. 삼각대 고정, 벽면에 슬랜티드 엣지(45도 기울인 흑백 경계) 또는 지멘스 스타 차트 부착
2. 조리개를 f/2.8, f/4, f/5.6, f/8, f/11로 바꾸며 촬영 (노출로 밝기 보상)
3. 각 이미지에서 측정:
- **ORB/FAST 특징점 개수** ← SLAM에는 이게 MTF보다 직접적인 지표
- 슬랜티드 엣지 MTF50 (`sfrmat`, `MTF Mapper` 등)
- 중앙 / 코너 각각 따로
4. 특징점 개수가 최대가 되는 조리개를 선택
---
## 3. 조리개 결정 절차 — "가장 어두운 곳 기준" 구체화
질문: *어두운 곳 기준으로 조리개를 맞출 때, 그때의 노출값과 게인값은 무엇으로 두는가?*
**답: 물리적 한계값(5000 µs, 16 dB)이 아니라, "품질 허용 한계값"으로 둡니다.** 그래야 여유(headroom)가 남습니다.
### 3.1 Step 1 — 노출 상한 `t_blur_max` 를 계산으로 결정
```
블러(px) ≈ f_px · ω · t_exp + f_px · v⊥ · t_exp / Z
f_px : 초점거리 [픽셀]
ω : 각속도 [rad/s] ← 핸드헬드에서 지배적
v⊥ : 시선 수직 속도 [m/s]
Z : 피사체 거리 [m]
```
핸드헬드 보행 기준 실측 권장값: IMU 자이로 로그에서 **각속도 95 퍼센타일**을 뽑아 쓰세요. 몸을 돌리거나 코너를 돌 때 1~2 rad/s가 흔히 나옵니다.
**우리 렌즈+센서 조합의 실제 f_px:**
```
f_px = f_lens[mm] / pixel_pitch[mm] = 4 / 0.00345 ≈ 1159 px
```
(`VM0420MP5` 4mm + IMX273 3.45 µm 기준. 실측 캘리브레이션 `JUL8_calib.md`의 cam1 fx=1200.0 / cam2 fx=1196.8과도 거의 일치 — 이 식이 신뢰할 만함을 뒷받침합니다.)
이 값은 아래 원래 예시가 가정했던 f_px=500(표준 화각)이나 f_px=300(광각)보다 훨씬 큽니다 — 즉 **이 렌즈는 예시들보다 화각이 좁아서(수평 약 64°), 같은 각속도에서도 블러 허용 노출시간이 훨씬 짧습니다.**
실제 f_px=1159 기준 예시 (ω = 1.5 rad/s, 블러 목표 1.0 px):
```
t_blur_max = 1.0 / (1159 · 1.5) = 0.58 ms
```
실제 f_px=1159 기준 예시 (ω = 1.0 rad/s, 블러 목표 1.5 px, 조금 더 관대한 목표):
```
t_blur_max = 1.5 / (1159 · 1.0) = 1.29 ms
```
참고용 원래 예시 (다른 화각의 렌즈였다면):
```
f_px = 500, ω = 1.5 rad/s, 블러 목표 1.0 px → t_blur_max = 1.33 ms
f_px = 300(광각), ω = 1.0 rad/s, 블러 목표 1.5 px → t_blur_max = 5.0 ms
```
**초점거리가 짧을수록(광각) 노출을 길게 쓸 수 있습니다.** 우리 렌즈는 광각이 아니라서(4mm/1160px), 요약표의 "보통 2~3 ms"는 과도한 값입니다 — 실제로는 **0.6~1.3 ms** 수준을 목표로 잡고, 최종 값은 반드시 실제 IMU 각속도 95퍼센타일로 재계산하세요. 이 값이 짧아지면 1.4절의 다크사이드 EV 계산도 함께 나빠진다는 점에 유의합니다(노출 상한이 줄면 어두운 장면을 커버할 능력도 줄어듦).
### 3.2 Step 2 — 실사용 게인 상한 `g_work` 를 실측으로 결정
게인을 0 / 6 / 12 / 16.9 dB로 바꿔가며 동일 장면 촬영(이 카메라는 16.9 dB가 물리적 상한이라 18/24 dB는 설정 자체가 안 됨) → ORB 특징점 개수와 매칭 인라이어 비율을 측정 → 급격히 꺾이는 지점 직전을 `g_work`로 잡습니다. 보통 **12 dB 근처**입니다.
`g_max`(비상 상한)는 통상 `g_work + 12 dB` 정도로 열어두라고 하지만, **이 카메라(MV-CS016-10UC)는 게인 자체가 0~16.9 dB까지만 되므로 그 공식을 그대로 쓸 수 없습니다.** 예: `g_work = 12 dB`인 경우, `g_max`는 24 dB가 아니라 **하드웨어 물리적 한계인 16.9 dB**이고, 비상용으로 실제 확보되는 여유는 `16.9 - 12 = 4.9 dB(0.81 stop)`뿐입니다. `g_work`를 더 낮게 잡을수록(예: 8 dB) 이 여유는 늘어나지만 대신 실사용 노출이 그만큼 길어져야 합니다.
### 3.3 Step 3 — 가장 어두운 목표 환경에서 조리개 조절
1. 실제로 데이터를 취득해야 하는 **가장 어두운 장소**로 이동 (지하주차장? 실내 복도? 야간?)
2. 카메라를 **수동 모드**로 두고 `t = t_blur_max`, `gain = g_work` 로 고정
3. **조리개 링을 돌려가며** 히스토그램 중앙값(median)이 8-bit 기준 **DN 100~120**이 되는 지점을 찾음
4. 그 위치에서 조리개를 **기계적으로 고정** (고정 나사 + 나사고정제/락타이트 또는 매니큐어). 포커스 링도 동일하게 고정.
5. 카메라 3대 모두 동일 f-number가 되도록 맞춤 (렌즈 개체 편차가 있으므로 눈금이 아니라 **실측 밝기**로 맞추세요)
### 3.4 Step 4 — 밝은 쪽 검증
1. 맑은 날 직사광 실외로 이동
2. `t = t_min`, `gain = 0 dB`
3. 관심 영역(하늘 제외)의 **98 퍼센타일 < DN 250** 인지 확인
4. 포화되면 → **ND 필터** 추가 (ND2 = 1 stop, ND4 = 2 stop, ND8 = 3 stop)
- 얇은 평행 평판 ND는 내부 파라미터에 유의미한 영향 없음
- 단, 실제 사용 상태(필터 장착 상태)로 캘리브레이션 재검증 권장
5. 포화 안 되면 → ND 불필요. 위 EV 계산상 **f/4 + 100 µs 이면 EV 17.3까지 커버**하므로 ND 없이 가능성이 높습니다.
### 3.5 Step 5 — 조리개 고정 후 캘리브레이션
조리개와 포커스를 확정한 **바로 그 상태**로:
- 각 카메라 내부 캘리브레이션 (intrinsic + distortion)
- 카메라 간 / 카메라-LiDAR 외부 캘리브레이션
- **비네팅 맵**과 **광응답함수(CRF)** 도 이때 함께 측정해두면 후처리에서 크게 유리 (7절 참조)
이후 조리개/포커스 링에 **표식(마킹)** 을 남기고, 세션마다 사진을 찍어 변화 여부를 기록하세요.
---
## 4. 측광 지표 (Metering Metric) — 논문 요약
### 4.1 현재 방식의 문제: 평균 밝기 (Mean Intensity)
```
목표: mean(I) = 128 이 되도록 노출 조절
```
**문제:** 프레임의 20%를 차지하는 하늘(DN 255)이 평균을 끌어올림 → 컨트롤러가 노출을 줄임 → 정작 특징점이 있는 지면/건물이 DN 30~50으로 뭉개짐. **관측하신 현상이 정확히 이것입니다.**
더 근본적으로, 평균 밝기는 **"이미지에 정보가 얼마나 있는가"와 아무 상관이 없습니다.** 평균 128인 완전 균일한 회색 이미지는 특징점이 0개입니다.
### 4.2 즉시 적용 가능한 개선: ROI + 퍼센타일 측광
논문 구현 없이 **오늘 바꿀 수 있는 것**이고, 개선 폭이 가장 큽니다.
```
1) ROI 마스크: 프레임 상단 1/3 제외 (하늘) 또는 LiDAR 깊이로 원거리 영역 제외
2) 제어 목표: ROI 내부의 p70 퍼센타일 → DN 130
3) 하드 제약: ROI 내부의 p98 퍼센타일 < DN 245 (포화 방지)
4) 하늘이 포화되는 것은 허용 — SLAM에 하늘 특징점은 어차피 불필요
```
퍼센타일은 소수의 극단적으로 밝은 화소에 흔들리지 않으므로, 평균 대비 훨씬 안정적입니다.
### 4.3 Shim et al. — Gradient 기반 노출 제어
- *"Auto-adjusting camera exposure for outdoor robotics using gradient information"*, IROS 2014
- *"Gradient-based Camera Exposure Control for Outdoor Mobile Platforms"*, arXiv:1708.07338 / IEEE TCSVT
**핵심 아이디어:** 대부분의 영상처리 알고리즘(코너 검출, 디스크립터, 스테레오 매칭)이 **로컬 gradient**에 의존하므로, 밝기가 아니라 **gradient 총량이 최대가 되는 노출**을 찾자.
**동작 방식:**
1. 현재 이미지 `I`에 여러 감마 값 `γ`를 적용해 "다른 노출로 찍었다면 어땠을지"를 **합성**
`I_γ = 255 · (I/255)^γ``γ < 1`이면 밝아진 이미지, `γ > 1`이면 어두운 이미지
2. 각 합성 이미지에서 Sobel gradient 크기 맵을 계산
3. 특별한 매핑 함수를 적용해 gradient를 합산:
- 아주 작은 gradient(노이즈)는 임계값 `δ` 이하로 **버림**
- 큰 gradient는 log 압축해서 **몇 개의 강한 엣지가 점수를 독점하지 못하게 함**
- 결과적으로 **"적당한 크기의 gradient가 많이 있는 상태"** 가 최고점
4. 점수가 최대인 `γ*`를 찾음
5. `γ* < 1` 이면 노출을 올리고, `γ* > 1` 이면 내림
실무 제어 법칙(비례 제어, 상수는 튜닝):
```
Δstop = -k · log2(γ*) k ∈ [0.5, 1.0]
t_next = t_curr · 2^(Δstop)
```
**멀티카메라 확장 (우리 케이스에 직접 해당):** 논문은 이 개념을 다중 카메라 시스템으로 확장해, **인접 카메라 간 밝기 일관성**과 **각 카메라의 적정 노출**을 동시에 만족하는 제어 알고리즘을 제시합니다. 각 카메라가 독립 AE로 돌면 크로스 카메라 매칭과 파노라마 스티칭이 깨지는데, 이를 비용함수에 일관성 항으로 넣어 해결합니다.
**장점:** 광응답함수 캘리브레이션이 필요 없음 (감마 근사만 사용) → 구현 난이도 낮음
**단점:** 여러 γ를 sweep 해야 해서 계산량이 있음 (후처리 시스템이면 문제 없음)
### 4.4 Zhang, Forster, Scaramuzza — Active Exposure Control
- *"Active Exposure Control for Robust Visual Odometry in HDR Environments"*, ICRA 2017
- 코드: `github.com/uzh-rpg/active_camera_exposure_control` (GPLv3)
**Shim 대비 차이:** 감마로 근사하는 대신 **카메라의 실제 광응답함수(CRF)를 오프라인 캘리브레이션**해 두고, 이를 이용해 "노출 시간이 t일 때의 이미지"를 **해석적으로 정확히 예측**합니다.
그 위에서 gradient 지표를 **노출 시간에 대해 미분**해서 gradient ascent로 최적점을 찾습니다. Sweep이 아니라 미분이므로 훨씬 빠릅니다.
논문은 SVO(Semi-direct Visual Odometry)를 가변 노출에 대응하도록 수정해 붙였고, 카메라 내장 AE와 고정 노출 양쪽 모두보다 좋은 결과를 보였습니다.
**위 저장소에는 4가지 방법이 모두 구현되어 있어 바로 비교 가능합니다:**
- `kMeanIntensity` — 평균 밝기 (baseline, 현재 방식)
- `kPercentile` — 퍼센타일 기반 (4.2절 방식)
- `kGradient` — Zhang et al. gradient 미분 방식
- `kShim` — Shim et al. sweep 방식
**→ 실무 착수점으로 이 저장소를 그대로 쓰는 것을 추천합니다.**
### 4.5 Kim, Cho, Kim — Bayesian Optimization
- *"Exposure control using Bayesian optimization based on entropy weighted image gradient"*, ICRA 2018
Gradient에 **엔트로피 가중치**를 곱한 지표를 쓰고, 국소 gradient ascent 대신 **베이지안 최적화**로 전역 탐색합니다. 목적함수가 다봉(multi-modal)일 때 국소 최적에 빠지지 않는 것이 장점이고, 적은 샘플로 수렴합니다.
### 4.6 최신 흐름 — Region-weighted / Motion-aware
- *"Region-weighted gradient and motion-aware camera exposure control for robust visual odometry"* (2025)
Gradient 지표에 **영역별 가중치**(가까운 구조물 > 먼 배경)와 **모션 블러 페널티**를 추가합니다. IMU 각속도를 읽어서 "지금 빠르게 회전 중이니 노출을 길게 못 쓴다"를 제어에 반영하는 방식으로, 3.1절의 블러 계산을 실시간으로 하는 것에 해당합니다. VINS-Mono와 ORB-SLAM3 양쪽에서 정확도 향상을 보고합니다.
또한 *BorealHDR* 라는 다중 노출 스테레오 데이터셋(8.4 km, 50 궤적, LiDAR 기반 3D 맵 및 포즈 GT 포함)이 공개되어 있어, **AE 알고리즘을 재현 가능하게 오프라인 벤치마크**할 수 있습니다. 후처리 파이프라인이시니 이런 에뮬레이터 방식이 잘 맞습니다.
### 4.7 적용 우선순위
```
1단계 (즉시) : ROI + 퍼센타일 측광으로 교체 ← 개선폭 최대, 비용 최소
2단계 (1~2주) : uzh-rpg 저장소 kShim / kGradient 적용
3단계 : 3대 카메라 밝기 일관성 항 추가 (Shim 멀티카메라)
4단계 : IMU 각속도 연동 노출 상한 동적 조절
```
---
## 5. 노출 중심(Exposure-Midpoint) 타임스탬프
### 5.1 문제 정의
하드웨어 트리거는 **노출의 시작**을 결정합니다. 하지만 이미지가 실제로 표현하는 시각은 **노출 구간의 중심**입니다.
```
트리거 엣지 ────┬──────────────────────────────────> 시간
├─ t_latency ─┤
├──── t_exp ────┤
│ ↑ │
노출시작 유효시각 노출종료
(t_exp/2)
```
카메라 3대가 각자 AE로 돌면 `t_exp`가 서로 다릅니다. **같은 트리거 엣지를 받아도 유효 시각이 서로 어긋납니다.**
```
카메라 A: t_exp = 3000 µs → 유효시각 = t_trig + Δ + 1500 µs
카메라 B: t_exp = 200 µs → 유효시각 = t_trig + Δ + 100 µs
차이 = 1400 µs = 1.4 ms
```
### 5.2 오차 크기 — 핸드헬드 보행 기준
**병진 오차:**
```
보행 1.4 m/s × 1.4 ms = 2.0 mm ← 무시 가능
```
**회전 오차 (이쪽이 지배적):**
```
각속도 1.5 rad/s × 1.4 ms = 2.1 mrad = 0.12°
픽셀 환산: f_px 500 × 2.1 mrad = 1.05 px
```
**→ 1 px 수준의 재투영 오차**입니다. 일반적인 RANSAC 인라이어 임계값(1~2 px)과 같은 수준이라 무시하기 어렵습니다. 노출 차이가 최대(100 vs 5000 µs)일 때는 2.45 ms → **1.8 px**까지 커집니다.
특히 **핸드헬드로 몸을 돌릴 때 각속도가 순간적으로 2~3 rad/s**까지 올라가고, 하필 그 순간이 실내→실외 전환처럼 노출이 크게 다른 상황과 겹치기 쉽습니다.
### 5.3 해결 절차
**[A] 트리거→노출시작 지연 `t_latency` 를 1회 실측**
대부분의 머신비전 카메라는 GPIO에 **Strobe / ExposureActive** 출력이 있습니다. 이 신호는 실제 센서 적분 구간 동안만 HIGH입니다.
```
1. 오실로스코프 CH1 = 트리거 입력, CH2 = ExposureActive 출력
2. 트리거 rising edge → ExposureActive rising edge 까지의 시간 = t_latency
3. ExposureActive의 HIGH 폭 = 실제 t_exp (설정값과 다를 수 있음 — 반드시 확인)
4. 카메라 3대 각각 측정 (개체차 있음)
5. 노출 시간을 100 µs / 1000 µs / 5000 µs로 바꿔가며 t_latency가 일정한지 확인
```
**[B] 프레임별 실제 노출 시간을 로깅**
GenICam Chunk Data를 활성화합니다:
```
ChunkModeActive = True
ChunkSelector = ExposureTime → ChunkEnable = True
ChunkSelector = Gain → ChunkEnable = True
ChunkSelector = Timestamp → ChunkEnable = True
ChunkSelector = FrameID → ChunkEnable = True
```
이러면 **각 이미지에 실제 적용된 노출/게인이 이미지와 함께 원자적으로** 붙어옵니다. AE는 명령과 실제 적용 사이에 1~2프레임 지연이 있으므로, **설정값이 아니라 chunk 값을 써야 합니다.**
**[C] 후처리에서 타임스탬프 보정**
```python
t_effective = t_trigger + t_latency + (t_exp_chunk / 2.0)
```
ROS 환경이라면 bag을 다시 쓰거나(`header.stamp` 재작성), 별도 CSV 사이드카를 만들어 SLAM 입력 시점에 적용합니다.
**[D] 검증**
보정 전/후로 다음을 비교:
- 회전 구간에서의 재투영 오차 RMS
- 카메라 간 특징점 매칭 인라이어 비율
- LiDAR 포인트를 이미지에 투영했을 때의 엣지 정합도 (회전 구간에서 특히)
보정이 맞다면 **회전이 빠른 구간에서 개선폭이 크게 나타나야** 합니다. 정지 구간에서는 차이가 없어야 정상입니다.
### 5.4 대안 / 보완 전략
**대안 1 — 노출 시간 동기화 (Master-Slave)**
3대의 `t_exp`를 항상 동일하게 강제하고, 밝기 차이는 **게인으로만** 흡수합니다. 유효 시각이 구조적으로 일치하므로 보정이 불필요합니다.
- 장점: 가장 확실, 후처리 단순
- 단점: 태양을 정면으로 보는 카메라와 그늘을 보는 카메라의 노출 요구가 3~4 stop 차이날 때, 게인만으로는 2.7 stop(16 dB)밖에 못 메움
**대안 2 — 노출 비율 제한 (절충안, 추천)**
3대의 `t_exp` 비율을 예를 들어 **최대 2:1 이내**로 제한합니다. 그러면 최대 유효시각 차이가 `t_max/4`로 묶입니다.
```
t_max = 3000 µs → 최대 어긋남 750 µs → 회전 1.5 rad/s에서 0.56 px
```
여기에 [C]의 보정까지 하면 잔차는 무시할 수준이 됩니다.
**대안 3 — TriggerDelay 사전 보정**
카메라의 `TriggerDelay` 레지스터로 노출 시작을 미리 당겨서 중심을 맞추는 방법입니다. 다만 다음 프레임의 `t_exp`를 미리 알아야 하므로, AE가 천천히 변할 때만 근사적으로 동작합니다. **후처리 시스템이면 [C]가 더 정확하고 간단합니다.**
### 5.5 LiDAR 쪽 체크리스트
- LiDAR 포인트가 **포인트 단위 타임스탬프**를 갖는지 확인 (스캔 단위만 있으면 deskew 불가)
- IMU로 **모션 디스큐(motion compensation)** 적용 여부 확인
- 카메라와 LiDAR가 **동일 시간 기준**(PTP / PPS+NMEA / 공통 트리거 카운터)을 쓰는지 확인
- LiDAR 스캔 구간의 어느 시점을 "스캔 시각"으로 잡을지 규약을 문서화 (스캔 시작? 중심?) — 카메라 중심시각 규약과 일관되게
---
## 6. 12-bit RAW 저장이란
### 6.1 무엇이 다른가
**센서 ADC는 원래 10~12 bit입니다.** 8-bit 출력을 선택하면 카메라가 내부에서 감마/LUT를 적용하고 8-bit로 잘라서 내보냅니다. 이때 버려진 정보는 **영구히 복구 불가**입니다.
| | 8-bit | 12-bit |
|---|---|---|
| 밝기 계단 수 | 256 | 4096 |
| 카메라 내부 처리 | 감마/LUT 적용됨 | 선형 그대로 |
| 하늘 근처 계조 | 뭉개짐 | 살아있음 |
| 그림자 디테일 | 소실 | 후처리로 끌어올림 가능 |
**"RAW"의 의미:** 카메라가 감마, 화이트밸런스, 샤프닝, 디모자이킹(컬러의 경우)을 **적용하지 않은 상태**. 이 처리들을 후처리 PC에서 우리가 원하는 대로 수행합니다.
### 6.2 후처리 시스템에서의 실질적 가치
핵심은 **"12→8 bit 변환 규칙을 언제든 다시 정할 수 있다"** 는 것입니다.
```
[취득] 12-bit 선형 raw → 디스크 저장 (한 번만 찍음)
[후처리] ├─→ 감마 2.2 → 8-bit → SLAM 시도 1
├─→ CLAHE → 8-bit → SLAM 시도 2
└─→ 로컬 톤매핑 → 8-bit → SLAM 시도 3
```
**8-bit로 저장했다면, 결과가 나쁠 때 현장에 다시 나가야 합니다.** 12-bit raw면 책상에서 재처리합니다. 이게 후처리 파이프라인의 가장 큰 이점입니다.
### 6.3 PixelFormat 선택
| 포맷 | 바이트/픽셀 | 비고 |
|---|---|---|
| `Mono8` | 1.0 | 현재 추정 |
| `Mono12Packed` / `Mono12p` | **1.5** | 12-bit를 압축 패킹 — 권장 |
| `Mono16` | 2.0 | 12-bit 데이터를 16-bit 컨테이너에 담음, 낭비 |
| `BayerRG12Packed` | 1.5 | 컬러 센서. 디모자이킹은 후처리에서 |
| `RGB8` | 3.0 | 카메라 내부 디모자이킹 — **피하세요** |
**컬러 카메라라면 반드시 Bayer raw로 저장하세요.** 카메라 내부 디모자이킹은 대역폭 3배에 품질도 나쁩니다.
### 6.4 대역폭/저장 용량 계산
```
초당 데이터량 = 카메라수 × 해상도 × 바이트/픽셀 × FPS
```
예시 (5 MP, 20 FPS, 3대):
```
Mono8 : 3 × 5.0e6 × 1.0 × 20 = 300 MB/s → 1시간 1.08 TB
Mono12p : 3 × 5.0e6 × 1.5 × 20 = 450 MB/s → 1시간 1.62 TB
```
**요구사항:**
- **NVMe SSD 필수** (SATA SSD 550 MB/s는 여유가 없음, HDD는 불가)
- 인터페이스 대역폭 확인: GigE 125 MB/s / 10GigE 1.25 GB/s / USB3 400 MB/s / CoaXPress
- **무손실 압축** 고려: 12-bit 선형 데이터는 `zstd -1` 이나 `lz4` 로 실시간 압축이 가능하고, 보통 1.5~2배 줄어듭니다. 손실 압축(JPEG)은 특징점 품질에 영향을 주므로 피하세요.
### 6.5 확인해야 할 함정
- **프레임레이트 저하:** 일부 센서는 12-bit에서 리드아웃이 느려져 최대 FPS가 떨어집니다. 데이터시트 확인 필수.
- **대역폭 초과 시 프레임 드롭:** 드롭이 생기면 하드웨어 동기가 무의미해집니다. **FrameID 연속성을 반드시 로깅/검증**하세요 (7절).
- **Gamma / LUT 비활성화 확인:** `Gamma = 1.0`, `LUTEnable = False`. 이게 켜져 있으면 raw가 raw가 아닙니다.
- **BlackLevel 값 기록:** 선형성 복원에 필요합니다.
### 6.6 후처리 파이프라인 권장 순서
```
12-bit 선형 raw
↓ BlackLevel 차감
↓ (컬러면) 디모자이킹
↓ 비네팅 보정 (플랫필드 맵)
↓ 노출/게인으로 정규화 ← photometric 일관성 확보
↓ 톤매핑: 감마 또는 CLAHE
8-bit mono → SLAM 프론트엔드
```
**CLAHE 사용 시 주의:** `clipLimit`, `tileGridSize`를 **전 프레임에 걸쳐 고정**하세요. 프레임마다 다르면 밝기 일관성이 깨져서 direct 방식이나 루프클로저에 악영향입니다.
---
## 7. 메타데이터 로깅 대상 정리
### 7.1 프레임 단위 (매 이미지마다)
| 항목 | 출처 | 왜 필요한가 |
|---|---|---|
| `frame_id` (시퀀스 번호) | Chunk `FrameID` | **드롭 프레임 검출** — 가장 중요 |
| `trigger_count` | 트리거 보드 | 트리거와 프레임의 1:1 대응 검증 |
| `device_timestamp` | Chunk `Timestamp` | 카메라 내부 클럭 |
| `host_timestamp` | 수신 PC | 클럭 드리프트 추정용 |
| **`exposure_time_us`** | **Chunk `ExposureTime`** | **타임스탬프 보정 + 광도 정규화** |
| **`gain_db`** | **Chunk `Gain`** | **광도 정규화** |
| `black_level` | Chunk / 주기적 | 선형성 복원 |
| `device_temperature` | `DeviceTemperature` | 암전류 변화, 캘리브레이션 열드리프트 |
| `pixel_format` | 설정 | 후처리 디코딩 |
| `wb_gains` (컬러) | Chunk | 컬러 일관성 |
| `ae_state` | 컨트롤러 | AE 수렴/포화 여부 디버깅 |
| `metering_score` | 컨트롤러 | 측광 지표 값 (튜닝 분석용) |
### 7.2 세션 단위 (취득 1회마다, YAML 매니페스트)
```yaml
session:
id: 2026-08-13_park_001
datetime_start: 2026-08-13T14:20:00+09:00
environment: outdoor_sunny # indoor / outdoor / mixed
weather: clear
platform: handheld
operator: ...
cameras:
- name: cam0
model: ...
serial: ...
firmware: ...
lens_model: ...
lens_serial: ...
aperture_fnumber: 4.0 # ★ 반드시 기록
focus_setting: hyperfocal_marked
nd_filter: none # none / ND4 / ND8
pixel_format: Mono12p
resolution: [2448, 2048]
roi: [0, 0, 2448, 2048]
binning: 1
gamma_enabled: false
lut_enabled: false
exposure_limits_us: [30, 3000]
gain_limits_db: [0, 17] # MV-CS016-10UC 실측 하드웨어 상한 (24 불가)
ae_algorithm: shim_gradient_v2
ae_roi_mask: masks/cam0_sky_exclude.png
sync:
trigger_source: external_board
trigger_freq_hz: 20
trigger_latency_us: # ★ 오실로스코프 실측값
cam0: 12.4
cam1: 12.1
cam2: 12.8
time_base: PTP # PTP / PPS+NMEA / trigger_counter
lidar:
model: ...
scan_rate_hz: 10
per_point_timestamp: true
sync_mode: PPS+NMEA
deskew_applied: false # 후처리에서 수행
imu:
model: ...
rate_hz: 200
calibration:
intrinsics_file: calib/intrinsics_2026-08-10.yaml
intrinsics_sha256: ...
extrinsics_file: calib/extrinsics_2026-08-10.yaml
extrinsics_sha256: ...
calibrated_aperture: 4.0 # ★ 세션 조리개와 일치하는지 검증
photometric_crf: calib/crf_cam0.txt
vignetting_map: calib/vignette_cam0.png
storage:
raw_path: /data/2026-08-13_park_001/
compression: zstd-1
```
### 7.3 필수 검증 스크립트 (후처리 첫 단계)
취득 직후 자동으로 돌려야 하는 검사들:
```
[ ] FrameID 연속성 — 카메라 3대 모두 결번 없는가
[ ] 트리거 카운트 == 프레임 카운트
[ ] 3대 프레임 개수 일치
[ ] 노출 시간 히스토그램 — 상한/하한에 붙어있는 프레임 비율
→ 상한에 계속 붙어있으면 조리개가 너무 조여진 것
[ ] 포화 화소 비율 (DN > 4000 in 12-bit)
→ ROI 내에서 1% 넘으면 경고
[ ] 언더 화소 비율 (DN < 100 in 12-bit)
[ ] 카메라 간 노출 시간 비율 — 2:1 초과 프레임 수
[ ] device_timestamp vs host_timestamp 드리프트 추이
[ ] 온도 변화 폭 — 세션 중 10°C 이상 변하면 캘리브레이션 재검토
```
이 검사를 통과 못 하면 **현장을 떠나기 전에** 재취득하는 게 원칙입니다.
---
## 8. Claude Code 작업 지시용 태스크 목록
개발 PC에서 착수할 때 이 순서를 권장합니다.
### Phase 1 — 진단 도구 (반나절)
1. `ev_coverage.py` — 1.4절 EV 커버리지 계산기. 조리개/노출/게인 조합 입력 → 커버 EV 범위 출력, 목표 환경 EV 리스트와 비교해 부족분 표시
2. `blur_budget.py` — 3.1절 블러 계산기. IMU 로그(rosbag/csv) 입력 → 각속도 퍼센타일 통계 → 목표 블러(px) 대비 `t_blur_max` 산출
3. `bag_qc.py` — 7.3절 검증 스크립트 전체
### Phase 2 — 광학 특성 실측 (1일, 현장 작업 포함)
4. `aperture_sweep_eval.py` — 조리개별 촬영 이미지 폴더 입력 → ORB 특징점 수, 매칭 반복성, MTF50을 조리개별로 플롯
5. `gain_noise_eval.py` — 게인별 SNR 및 특징점 품질 곡선 → `g_work` 결정
6. 오실로스코프 측정으로 `trigger_latency` 3대 실측 (수작업, 결과를 YAML에 기록)
### Phase 3 — 타임스탬프 보정 (1~2일)
7. Chunk Data 활성화 (`ExposureTime`, `Gain`, `Timestamp`, `FrameID`) 및 로깅 경로 구현
8. `retimestamp.py``t_trig + t_latency + t_exp/2` 로 타임스탬프 재작성. rosbag 재작성 또는 사이드카 CSV 생성
9. 보정 전/후 재투영 오차 비교 리포트 생성 (회전 구간 별도 집계)
### Phase 4 — 노출 제어 개선 (1~2주)
10. ROI 마스크 생성 도구 (수동 폴리곤 또는 LiDAR 깊이 기반 자동)
11. 퍼센타일 측광 컨트롤러 구현 및 교체 (4.2절)
12. `uzh-rpg/active_camera_exposure_control` 통합, `kShim`/`kGradient` 비교 평가
13. 3대 밝기 일관성 항 추가 (Shim 멀티카메라 방식)
14. 노출 비율 2:1 제한 로직 추가 (5.4절 대안 2)
### Phase 5 — 후처리 파이프라인 (1주)
15. `Mono12p` 전환, 대역폭/드롭 검증
16. 광응답함수(CRF) + 비네팅 맵 캘리브레이션 도구
17. 12→8 bit 톤매핑 파이프라인 (감마 / CLAHE / 로컬 톤매핑 선택 가능하게, 파라미터는 세션 전체 고정)
18. 노출·게인 기반 광도 정규화 적용
---
## 9. 참고문헌
- I. Shim, J.-Y. Lee, I. S. Kweon, "Auto-adjusting camera exposure for outdoor robotics using gradient information," IROS 2014
- I. Shim, T.-H. Oh, J.-Y. Lee, J. Choi, D.-G. Choi, I. S. Kweon, "Gradient-based Camera Exposure Control for Outdoor Mobile Platforms," arXiv:1708.07338 / IEEE TCSVT
- Z. Zhang, C. Forster, D. Scaramuzza, "Active Exposure Control for Robust Visual Odometry in HDR Environments," ICRA 2017
- 코드: https://github.com/uzh-rpg/active_camera_exposure_control
- J. Kim, Y. Cho, A. Kim, "Exposure control using Bayesian optimization based on entropy weighted image gradient," ICRA 2018
- "Region-weighted gradient and motion-aware camera exposure control for robust visual odometry," 2025
- BorealHDR — 다중 노출 스테레오 데이터셋 (AE 알고리즘 오프라인 재현 벤치마크용)
- J. Engel, V. Usenko, D. Cremers, "A Photometrically Calibrated Benchmark For Monocular Visual Odometry" (TUM mono dataset — 광응답함수/비네팅 캘리브레이션 방법론)
+27
View File
@@ -0,0 +1,27 @@
# 질문지
> 대상 카메라: **MV-CS016-10UC** (Sony IMX273, 1/2.9", 3.45 µm, global shutter, 1440×1080) — `resource/`에 첨부된 데이터시트 및 `hik_camera_ros2_driver` 코드/설정 실측 주석 기준으로 아래 질문들을 갱신함. 상세 답변은 `answer_1.md` 참조.
## 1. 노출 하한 값을 100 µs보다 낮출 수 있는가
데이터시트 기준 Standard exposure mode 하한은 15 µs다 (UltraShort 모드는 1~14 µs로 별도 모드). 현재 bag 녹화 시 3대 모두 `exposure_auto_min: 100.0`으로 설정되어 있는데, 15 µs 근방까지 낮추면 stop 계산법상 `log2(100/15) ≈ 2.74 stop`의 여유가 추가로 생긴다. 코드가 이미 쿼리하는 개체별 `ExposureTime` 노드의 실제 fMin 값(데이터시트는 스펙 상 보증치이며 개체 편차 가능)을 로그로 한 번 확인하고, `exposure_auto_min`을 실제 하한 근처로 낮춰 3대 모두 재검증이 필요한지 확인한다.
## 2. 게인 상한치를 16 dB보다 더 열 수 있는가
데이터시트 기준 게인 범위는 0~17 dB이며, `camera_params_cam1/2/3.yaml`의 실측 주석에도 "Range: 0.0 ~ 16.9 dB"로 이미 확인되어 있다. 즉 현재 16 dB 설정은 여유를 둔 보수적인 값이 아니라 **하드웨어 상한(16.9 dB)에 거의 붙은 값**이며, 24 dB 등으로 더 여는 것은 이 카메라에서 물리적으로 불가능하다. cam2는 현재 15 dB로, 상한까지 1.9 dB(0.3 stop)밖에 남지 않은 상태다. 실내 진입 시 필요한 다크사이드 stop 여유를 게인이 아니라 조리개(f/2.8 등) 또는 노출 상한(3.1절 블러 계산 기준) 쪽에서 확보하는 방향으로 조정할지 결정한다.
## 3. 저장 시 12-bit raw 및 프레임별 메타데이터를 저장할 수 있는가
현재 11분 촬영 시 3대 카메라(RGB8Packed) + mid360(10 Hz) + IMU(200 Hz) bag 용량이 100 GB 근방이며, 10 Hz 트리거 가정으로 역산한 카메라 데이터량(≈92 GB)과 대체로 맞아떨어진다.
드라이버(`hik_camera_node.cpp`)가 `convert_param_.enDstPixelType``RGB8Packed`로 고정해 매 프레임 강제 변환·발행하므로, `pixel_format`/`adc_bit_depth` 파라미터를 12-bit로 바꿔도 **실제로는 12-bit raw가 bag에 담기지 않는다**는 것이 코드로 확인되었다. 이 강제 변환 로직을 없애고 raw 패스스루(예: `mono16`/`bayer_rggb16` 인코딩)로 바꾸는 작업을 언제 반영할지, 그리고 이때 예상 용량(현재보다 33~67% 감소 예상 — BayerRG12Packed 1.5 byte/px 또는 Mono16 2 byte/px vs 현재 RGB8 3 byte/px)이 실측으로도 맞는지 확인한다.
프레임별 메타데이터(노출시간/게인/프레임번호)는 SDK가 `MV_FRAME_OUT_INFO_EX` 구조체(`fExposureTime`, `fGain`, `nFrameNum`, `nAverageBrightness` 등)로 이미 매 프레임 제공하고 있는데, 캡처 루프가 이를 읽지 않고 버리고 있다는 것도 확인되었다 (GenICam `ChunkModeActive`를 새로 켤 필요 없이 바로 쓸 수 있음). 3대 모두 `exposure_auto: true`라 실제 노출값이 프레임마다 바뀌는데 지금은 bag에서 사후 확인이 불가능한 상태이므로, 최소한 실제 노출/게인 값만이라도 별도 토픽이나 CSV로 로깅하는 작업을 언제 반영할지 정한다.
## 4. 측광 지표 관련 논문/정리 내용으로 현재 측광 지표를 개선할 수 있는가
현재 하늘이 포함된 이미지에서 바닥이 어두워지는 경향이 눈으로도 확인된다. 원인은 카메라 온보드 Continuous AE가 `AutoTargetBrightness` 단일 스칼라(전체 프레임 평균 밝기 기준)로만 동작하기 때문으로 확인되었다. SDK 헤더상 AE 계산에만 쓰는 별도 ROI 기능은 확인되지 않으며(`MV_CC_Set/GetAOIoffsetX/Y`는 촬영 크롭용), 이 부분은 Hikvision MVS Feature Tree에서 한 번 더 확인이 필요하다.
드라이버는 `exposure_auto: false` + 런타임 파라미터(`exposure_time`/`gain`, 이미 `dynamicParametersCallback`으로 지원됨)를 통해 외부 노드가 노출을 대신 제어할 수 있는 구조를 이미 갖고 있다. ROI+퍼센타일 측광(설계노트 4.2절)을 이 구조 위에 별도 ROS 2 노드로 구현하는 작업을 언제 착수할지 정한다.
## 5. 회전 시 재투영 오차와 GPIO 관련 절차 사용 가능 여부
핸즈헬드 장비를 회전하며 녹화 시 나무 등 근거리 물체 주변에 재투영 오차가 나타나는 현상은, 나무 자체의 문제가 아니라 **회전 중 카메라 간 노출 중심 타임스탬프 어긋남이 근거리·고밀도 특징점 물체에서 특히 두드러지는 일반적 현상**으로 확인되었다 (근거: 5.2절 블러 공식의 `v⊥/Z` 항 — 거리가 가까울수록 겉보기 오차가 커짐). 이 진단이 맞다면, 다음을 확인해서 5.3-[A] GPIO 오실로스코프 절차를 실제로 진행할지 정한다:
- 데이터시트 확인 결과 카메라 I/O는 Line0(opto-isolated 입력, 현재 트리거용으로 사용 중), Line1(opto-isolated 출력, **현재 미사용**), Line2(양방향 비절연)로 구성되어 있어 출력 라인 자체는 물리적으로 비어 있다.
- 다만 Hikvision MVS Feature Tree에서 Line1의 `LineSource``ExposureActive`/`Strobe` 옵션이 실제로 존재하는지, 그리고 현재 6-pin 케이블이 Line1을 실제로 브레이크아웃했는지는 아직 미확인 — 이 두 가지를 확인해야 절차 진행 여부를 최종 결정할 수 있다.
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
# hik_camera_panel
`hik_camera_ros2_driver`(cam1/cam2/cam3)의 노출·게인·ROI 파라미터를 슬라이더 또는 직접 숫자
입력으로 실시간 조절하는 패널. 카메라별 탭 안에 실시간 이미지 미리보기가 있고, ROI(측광 제외
상단 비율)를 조절하면 그 경계선이 이미지 위에 바로 그려진다.
## 실행
카메라 노드(`hik_camera_cam1`/`hik_camera_cam2`/`hik_camera_cam3`)가 먼저 떠 있어야 한다.
```bash
ros2 run hik_camera_panel panel_node
```
## 구성
- 탭 3개(cam1/cam2/cam3), 각 탭은 `/hik_camera_camN/get_parameters`·`/hik_camera_camN/set_parameters`
서비스를 직접 호출해서 값을 읽고 쓴다.
- 그룹: 노출 / 게인 / 소프트웨어 AE·ROI / 동기화.
- 숫자 파라미터는 슬라이더+스핀박스를 같이 제공한다. 슬라이더를 드래그하는 동안은 로컬
미리보기(ROI 오버레이)만 갱신되고, 손을 뗀 시점에 실제로 카메라에 값을 적용한다. 스핀박스는
값을 직접 입력하고 포커스를 벗어나면(또는 Enter) 바로 적용된다.
- 노출/노출상한/노출하한은 범위가 15µs~100ms로 넓어서 슬라이더를 로그 스케일로 매핑했다
(`param_spec.py``log_scale=True`).
- ROI 미리보기: 카메라의 `/<camera_name>/image` 토픽을 구독해서 QImage로 바로 그린다
(cv_bridge/OpenCV 의존성 없음 — 드라이버가 항상 `rgb8`로 발행하므로 `QImage.Format_RGB888`
직접 변환 가능).
## 알아둘 것
1. **`sync_role`/`sync_master_camera_ns`는 조회만 되고 편집은 막혀 있다.** 드라이버가 publisher/
subscriber를 노드 시작 시 `initSync()`에서 한 번만 만들기 때문에, 런타임에 이 값을 바꿔도
반영되지 않는다 (`hik_camera_node.cpp``dynamicParametersCallback()`도 이 두 파라미터는
처리하지 않음 — 시도하면 "Unknown parameter"로 거부됨). 마스터/슬레이브 역할을 바꾸려면
yaml을 고치고 노드를 재시작해야 한다.
2. **나머지 파라미터는 이번에 `hik_camera_node.cpp``dynamicParametersCallback()`을 확장해서
전부 런타임에 반영되도록 만들었다** (`gain_auto_max_db`, `ae_roi_top_ratio`,
`ae_target_percentile`, `ae_target_dn`, `ae_saturation_percentile`, `ae_saturation_dn`,
`ae_step_gain`). 이 패널이 슬라이더로 값을 바꿨을 때 실제로 카메라에 반영되려면 드라이버
쪽도 이 커밋 이후 버전이어야 한다.
3. **개발 PC(macOS)에는 ROS 2와 `python_qt_binding`이 없어서 실행 검증을 못 했다.** Python
문법 검사(`python3 -m py_compile`)와 슬라이더↔값 변환 로직의 라운드트립 테스트만 순수
Python으로 돌려봤고, 실제 rclpy 파라미터 서비스 호출·Qt 위젯 동작·이미지 렌더링은 로봇
PC에서 `ros2 run hik_camera_panel panel_node`로 직접 확인해야 한다.
4. 카메라 노드/토픽 이름이 `camera_params_cam{1,2,3}.yaml`
`hik_camera_triple_launch.py`와 다르면 `panel_node.py``DEFAULT_CAMERAS` 목록을 맞춰
수정할 것.
@@ -0,0 +1,440 @@
"""hik_camera_ros2_driver 노출/게인/ROI 파라미터 조절 패널.
카메라 노드(hik_camera_cam1/cam2/cam3)의 ROS 2 파라미터 서비스(get_parameters/
set_parameters)를 직접 호출해서 슬라이더/스핀박스로 값을 읽고 쓴다. ROI(측광 제외
상단 비율)는 카메라의 실시간 이미지 위에 경계선을 오버레이해서 눈으로 보면서
조절할 수 있게 했다.
실행:
ros2 run hik_camera_panel panel_node
전제:
- hik_camera_cam1/cam2/cam3 노드가 이미 떠 있어야 한다 (안 떠 있으면 "새로고침"/
슬라이더 조작 시 상태바에 실패 메시지가 뜬다).
- sync_role / sync_master_camera_ns는 조회만 가능하고 편집은 막아뒀다 — 드라이버가
publisher/subscriber를 노드 시작 시 한 번만 만들기 때문에 런타임에 값을 바꿔도
반영되지 않는다 (초기화 흐름을 다시 태우려면 노드 재시작 필요).
"""
import sys
import threading
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rcl_interfaces.msg import Parameter, ParameterType, ParameterValue
from rcl_interfaces.srv import GetParameters, SetParameters
from sensor_msgs.msg import Image
from python_qt_binding.QtCore import QObject, Qt, pyqtSignal
from python_qt_binding.QtGui import QColor, QImage, QPainter, QPen, QPixmap
from python_qt_binding.QtWidgets import (
QApplication,
QCheckBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QSlider,
QSpinBox,
QStatusBar,
QTabWidget,
QVBoxLayout,
QWidget,
)
from hik_camera_panel.param_spec import PARAM_SPEC_BY_NAME, PARAM_SPECS, SLIDER_STEPS, \
slider_to_value, value_to_slider
# (node_name, camera_name) — node_name은 파라미터 서비스(/<node_name>/set_parameters)용,
# camera_name은 이미지 토픽(/<camera_name>/image)용. hik_camera_triple_launch.py /
# camera_params_cam{1,2,3}.yaml과 일치해야 한다.
DEFAULT_CAMERAS = [
('hik_camera_cam1', 'cam1'),
('hik_camera_cam2', 'cam2'),
('hik_camera_cam3', 'cam3'),
]
def make_parameter_msg(name, type_str, value):
pv = ParameterValue()
if type_str == 'bool':
pv.type = ParameterType.PARAMETER_BOOL
pv.bool_value = bool(value)
elif type_str == 'int':
pv.type = ParameterType.PARAMETER_INTEGER
pv.integer_value = int(round(value))
elif type_str == 'double':
pv.type = ParameterType.PARAMETER_DOUBLE
pv.double_value = float(value)
else:
pv.type = ParameterType.PARAMETER_STRING
pv.string_value = str(value)
msg = Parameter()
msg.name = name
msg.value = pv
return msg
def parameter_value_to_python(pv):
if pv.type == ParameterType.PARAMETER_BOOL:
return pv.bool_value
if pv.type == ParameterType.PARAMETER_INTEGER:
return pv.integer_value
if pv.type == ParameterType.PARAMETER_DOUBLE:
return pv.double_value
if pv.type == ParameterType.PARAMETER_STRING:
return pv.string_value
return None
class RosBridge(QObject):
"""rclpy 노드를 백그라운드 스레드에서 spin하고, 결과는 Qt 시그널로 GUI 스레드에 넘긴다.
파라미터 서비스 호출은 항상 call_async + add_done_callback으로 비동기 처리한다.
add_done_callback은 spin 스레드에서 실행되므로, 그 안에서 위젯을 직접 건드리지 않고
시그널만 emit한다 (Qt가 큐잉해서 GUI 스레드에서 슬롯을 실행해준다).
"""
params_fetched = pyqtSignal(str, dict) # node_name, {param_name: value}
param_set_result = pyqtSignal(str, str, bool, str) # node_name, param_name, ok, reason
image_received = pyqtSignal(str, QImage) # camera_name, image
def __init__(self):
super().__init__()
self._node = Node('hik_camera_panel')
self._set_clients = {}
self._get_clients = {}
self._image_subs = {}
self._spin_thread = threading.Thread(target=self._spin, daemon=True)
self._spin_thread.start()
def _spin(self):
rclpy.spin(self._node)
# -- 서비스 클라이언트 --------------------------------------------------
def _set_client(self, node_name):
if node_name not in self._set_clients:
self._set_clients[node_name] = self._node.create_client(
SetParameters, f'/{node_name}/set_parameters')
return self._set_clients[node_name]
def _get_client(self, node_name):
if node_name not in self._get_clients:
self._get_clients[node_name] = self._node.create_client(
GetParameters, f'/{node_name}/get_parameters')
return self._get_clients[node_name]
def fetch_parameters(self, node_name, specs):
client = self._get_client(node_name)
if not client.service_is_ready():
self.param_set_result.emit(
node_name, '(새로고침)', False,
'파라미터 서비스에 연결할 수 없음 — 노드가 실행 중인지 확인하세요')
return
req = GetParameters.Request()
req.names = [s['name'] for s in specs]
future = client.call_async(req)
def _done(fut):
try:
resp = fut.result()
except Exception as exc: # noqa: BLE001 - 서비스 호출 자체 실패를 그대로 보고
self.param_set_result.emit(node_name, '(새로고침)', False, str(exc))
return
values = {}
for spec, pv in zip(specs, resp.values):
values[spec['name']] = parameter_value_to_python(pv)
self.params_fetched.emit(node_name, values)
future.add_done_callback(_done)
def set_parameter(self, node_name, name, type_str, value):
client = self._set_client(node_name)
if not client.service_is_ready():
self.param_set_result.emit(
node_name, name, False,
'파라미터 서비스에 연결할 수 없음 — 노드가 실행 중인지 확인하세요')
return
req = SetParameters.Request()
req.parameters = [make_parameter_msg(name, type_str, value)]
future = client.call_async(req)
def _done(fut):
try:
resp = fut.result()
except Exception as exc: # noqa: BLE001
self.param_set_result.emit(node_name, name, False, str(exc))
return
result = resp.results[0]
self.param_set_result.emit(node_name, name, result.successful, result.reason)
future.add_done_callback(_done)
# -- 이미지 구독 ----------------------------------------------------------
def subscribe_image(self, camera_name):
if camera_name in self._image_subs:
return
def _cb(msg):
if msg.encoding != 'rgb8':
return
qimg = QImage(
bytes(msg.data), msg.width, msg.height, msg.step, QImage.Format_RGB888).copy()
self.image_received.emit(camera_name, qimg)
self._image_subs[camera_name] = self._node.create_subscription(
Image, f'/{camera_name}/image', _cb, qos_profile_sensor_data)
def shutdown(self):
rclpy.shutdown()
class ParamRow(QWidget):
"""파라미터 하나를 표시하는 한 줄: bool=체크박스, string=텍스트박스(읽기전용 가능),
나머지(int/double)=슬라이더+스핀박스 동시 제공.
- previewChanged: 슬라이더 드래그 중(아직 손 안 뗌) 매번 emit — 로컬 미리보기(ROI 오버레이
등)만 갱신하고 아직 카메라에는 보내지 않음.
- valueEdited: 슬라이더에서 손을 떼거나, 스핀박스 편집을 마치거나, 체크박스를 토글했을 때
emit — 이때 실제로 ros2 파라미터를 설정한다.
"""
valueEdited = pyqtSignal(str, object)
previewChanged = pyqtSignal(str, object)
def __init__(self, spec, parent=None):
super().__init__(parent)
self.spec = spec
self._suppress = False
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.checkbox = None
self.line_edit = None
self.slider = None
self.spin = None
if spec['type'] == 'bool':
self.checkbox = QCheckBox()
self.checkbox.toggled.connect(self._on_bool_changed)
layout.addWidget(self.checkbox)
elif spec['type'] == 'string':
self.line_edit = QLineEdit()
self.line_edit.setReadOnly(spec.get('readonly', False))
if spec.get('readonly'):
self.line_edit.setToolTip('런타임 변경 미지원 — 노드 재시작 필요')
layout.addWidget(self.line_edit)
else:
self.slider = QSlider(Qt.Horizontal)
self.slider.setRange(0, SLIDER_STEPS)
if spec['type'] == 'int':
self.spin = QSpinBox()
self.spin.setRange(int(spec['min']), int(spec['max']))
else:
self.spin = QDoubleSpinBox()
self.spin.setRange(spec['min'], spec['max'])
decimals = spec.get('decimals', 2)
self.spin.setDecimals(decimals)
self.spin.setSingleStep(10 ** (-decimals) if decimals > 0 else 1.0)
layout.addWidget(self.slider, 3)
layout.addWidget(self.spin, 1)
self.slider.sliderMoved.connect(self._on_slider_moved)
self.slider.sliderReleased.connect(self._on_slider_released)
self.spin.editingFinished.connect(self._on_spin_edited)
def set_value(self, value):
self._suppress = True
try:
if self.checkbox is not None:
self.checkbox.setChecked(bool(value))
elif self.line_edit is not None:
self.line_edit.setText(str(value))
else:
self.spin.setValue(value)
self.slider.setValue(value_to_slider(
value, self.spec['min'], self.spec['max'], self.spec.get('log_scale', False)))
finally:
self._suppress = False
def _on_bool_changed(self, checked):
if not self._suppress:
self.valueEdited.emit(self.spec['name'], checked)
def _on_slider_moved(self, pos):
value = slider_to_value(
pos, self.spec['min'], self.spec['max'], self.spec.get('log_scale', False))
self._suppress = True
self.spin.setValue(value)
self._suppress = False
self.previewChanged.emit(self.spec['name'], value)
def _on_slider_released(self):
value = slider_to_value(
self.slider.value(), self.spec['min'], self.spec['max'],
self.spec.get('log_scale', False))
self.valueEdited.emit(self.spec['name'], value)
def _on_spin_edited(self):
if self._suppress:
return
value = self.spin.value()
self._suppress = True
self.slider.setValue(value_to_slider(
value, self.spec['min'], self.spec['max'], self.spec.get('log_scale', False)))
self._suppress = False
self.valueEdited.emit(self.spec['name'], value)
class CameraTab(QWidget):
def __init__(self, node_name, camera_name, bridge, parent=None):
super().__init__(parent)
self.node_name = node_name
self.camera_name = camera_name
self.bridge = bridge
self.rows = {}
self._latest_image = None
self._roi_ratio = 0.0
outer = QHBoxLayout(self)
left_layout = QVBoxLayout()
groups = {}
for spec in PARAM_SPECS:
group_name = spec['group']
if group_name not in groups:
box = QGroupBox(group_name)
box.setLayout(QFormLayout())
groups[group_name] = box
left_layout.addWidget(box)
row = ParamRow(spec)
row.valueEdited.connect(self._on_value_edited)
row.previewChanged.connect(self._on_preview_changed)
groups[group_name].layout().addRow(spec['label'], row)
self.rows[spec['name']] = row
refresh_btn = QPushButton('새로고침')
refresh_btn.clicked.connect(self.refresh)
left_layout.addWidget(refresh_btn)
left_layout.addStretch(1)
left_widget = QWidget()
left_widget.setLayout(left_layout)
self.preview = QLabel('이미지 대기 중...')
self.preview.setMinimumSize(480, 360)
self.preview.setAlignment(Qt.AlignCenter)
self.preview.setStyleSheet('background-color: #202020; color: #aaaaaa;')
outer.addWidget(left_widget, 2)
outer.addWidget(self.preview, 3)
self.bridge.params_fetched.connect(self._on_params_fetched)
self.bridge.image_received.connect(self._on_image_received)
self.bridge.subscribe_image(camera_name)
self.refresh()
def refresh(self):
self.bridge.fetch_parameters(self.node_name, PARAM_SPECS)
def _on_params_fetched(self, node_name, values):
if node_name != self.node_name:
return
for name, value in values.items():
if name in self.rows:
self.rows[name].set_value(value)
if 'ae_roi_top_ratio' in values:
self._roi_ratio = values['ae_roi_top_ratio']
self._redraw_preview()
def _on_value_edited(self, name, value):
spec = PARAM_SPEC_BY_NAME[name]
if spec.get('readonly'):
return
self.bridge.set_parameter(self.node_name, name, spec['type'], value)
if name == 'ae_roi_top_ratio':
self._roi_ratio = value
self._redraw_preview()
def _on_preview_changed(self, name, value):
if name == 'ae_roi_top_ratio':
self._roi_ratio = value
self._redraw_preview()
def _on_image_received(self, camera_name, qimg):
if camera_name != self.camera_name:
return
self._latest_image = qimg
self._redraw_preview()
def _redraw_preview(self):
if self._latest_image is None:
return
target_width = self.preview.width() if self.preview.width() > 0 else 480
pixmap = QPixmap.fromImage(self._latest_image).scaledToWidth(
target_width, Qt.SmoothTransformation)
painter = QPainter(pixmap)
w, h = pixmap.width(), pixmap.height()
boundary_y = int(h * self._roi_ratio)
if boundary_y > 0:
painter.fillRect(0, 0, w, boundary_y, QColor(0, 0, 0, 120))
pen = QPen(QColor(255, 60, 60))
pen.setWidth(2)
painter.setPen(pen)
painter.drawLine(0, boundary_y, w, boundary_y)
painter.end()
self.preview.setPixmap(pixmap)
class MainWindow(QMainWindow):
def __init__(self, bridge, cameras):
super().__init__()
self.setWindowTitle('hik_camera 노출 / 게인 / ROI 패널')
self.bridge = bridge
tabs = QTabWidget()
for node_name, camera_name in cameras:
tabs.addTab(CameraTab(node_name, camera_name, bridge), camera_name)
self.setCentralWidget(tabs)
self.setStatusBar(QStatusBar())
bridge.param_set_result.connect(self._on_param_set_result)
self.resize(1280, 720)
def _on_param_set_result(self, node_name, name, ok, reason):
if ok:
self.statusBar().showMessage(f'[{node_name}] {name} 적용됨', 2000)
else:
self.statusBar().showMessage(f'[{node_name}] {name} 실패: {reason}', 6000)
def main(args=None):
rclpy.init(args=args)
app = QApplication(sys.argv)
bridge = RosBridge()
window = MainWindow(bridge, DEFAULT_CAMERAS)
window.show()
exit_code = app.exec_()
bridge.shutdown()
sys.exit(exit_code)
if __name__ == '__main__':
main()
@@ -0,0 +1,81 @@
"""hik_camera_ros2_driver가 선언하는 파라미터 스펙.
이름/타입/범위는 hik_camera_node.cpp의 declareParameters()와 dynamicParametersCallback()에
선언/처리되는 것과 반드시 일치해야 한다 (그쪽을 바꾸면 여기도 같이 바꿀 것).
sync_role / sync_master_camera_ns는 런타임에 값을 바꿔도 드라이버가 무시한다
(subscription/publisher가 노드 시작 시 initSync()에서 한 번만 만들어짐) — 그래서
readonly=True로 표시해 패널에서는 조회만 하고 편집은 막는다.
"""
import math
SLIDER_STEPS = 1000
PARAM_SPECS = [
# --- 노출 ---
dict(name='exposure_auto', type='bool', group='노출', label='자동 노출'),
dict(name='exposure_time', type='int', group='노출', label='수동 노출시간 [us]',
min=15, max=100000, log_scale=True),
dict(name='exposure_auto_target_brightness', type='int', group='노출',
label='목표 밝기 (온보드 AE)', min=0, max=255),
dict(name='exposure_auto_min', type='double', group='노출', label='자동 노출 하한 [us]',
min=15.0, max=100000.0, log_scale=True, decimals=0),
dict(name='exposure_auto_max', type='double', group='노출', label='자동 노출 상한 [us]',
min=15.0, max=100000.0, log_scale=True, decimals=0),
# --- 게인 ---
dict(name='gain', type='double', group='게인', label='수동 게인 [dB]',
min=0.0, max=17.0, decimals=1),
dict(name='gain_auto', type='bool', group='게인', label='자동 게인'),
dict(name='gain_auto_max_db', type='double', group='게인', label='자동 게인 상한 [dB]',
min=0.0, max=17.0, decimals=1),
# --- 소프트웨어 AE / ROI ---
dict(name='use_software_ae', type='bool', group='소프트웨어 AE / ROI',
label='소프트웨어 AE 사용'),
dict(name='ae_roi_top_ratio', type='double', group='소프트웨어 AE / ROI',
label='측광 제외 상단 비율 (0.5=하단 절반만)', min=0.0, max=0.95, decimals=2),
dict(name='ae_target_percentile', type='double', group='소프트웨어 AE / ROI',
label='목표 퍼센타일', min=0.0, max=100.0, decimals=1),
dict(name='ae_target_dn', type='int', group='소프트웨어 AE / ROI',
label='목표 DN', min=0, max=255),
dict(name='ae_saturation_percentile', type='double', group='소프트웨어 AE / ROI',
label='포화 방지 퍼센타일', min=0.0, max=100.0, decimals=1),
dict(name='ae_saturation_dn', type='int', group='소프트웨어 AE / ROI',
label='포화 방지 DN', min=0, max=255),
dict(name='ae_step_gain', type='double', group='소프트웨어 AE / ROI',
label='보정 댐핑 (0~1)', min=0.0, max=1.0, decimals=2),
# --- 동기화 (재시작 필요 — 아래 readonly 참고) ---
dict(name='sync_role', type='string', group='동기화 (재시작 필요)', label='역할',
readonly=True),
dict(name='sync_master_camera_ns', type='string', group='동기화 (재시작 필요)',
label='마스터 camera_name', readonly=True),
]
PARAM_SPEC_BY_NAME = {spec['name']: spec for spec in PARAM_SPECS}
def value_to_slider(value, vmin, vmax, log_scale):
"""실제 값을 0~SLIDER_STEPS 정수 슬라이더 위치로 변환."""
value = min(max(value, vmin), vmax)
if log_scale:
vmin_eff = max(vmin, 1e-9)
value_eff = max(value, vmin_eff)
lo, hi = math.log(vmin_eff), math.log(vmax)
frac = (math.log(value_eff) - lo) / (hi - lo) if hi > lo else 0.0
else:
frac = (value - vmin) / (vmax - vmin) if vmax > vmin else 0.0
return int(round(frac * SLIDER_STEPS))
def slider_to_value(pos, vmin, vmax, log_scale):
"""슬라이더 위치(0~SLIDER_STEPS)를 실제 값으로 변환."""
frac = min(max(pos, 0), SLIDER_STEPS) / SLIDER_STEPS
if log_scale:
vmin_eff = max(vmin, 1e-9)
lo, hi = math.log(vmin_eff), math.log(vmax)
return math.exp(lo + frac * (hi - lo))
return vmin + frac * (vmax - vmin)
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>hik_camera_panel</name>
<version>1.0.0</version>
<description>
hik_camera_ros2_driver의 노출/게인/ROI 파라미터를 슬라이더 또는 직접 입력으로
실시간 조절하는 PyQt(python_qt_binding) 기반 패널. 카메라 3대(cam1/cam2/cam3) 탭과
ROI 경계가 오버레이된 실시간 이미지 미리보기를 제공한다.
</description>
<maintainer email="khj@example.com">khj</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_python</buildtool_depend>
<depend>rclpy</depend>
<depend>rcl_interfaces</depend>
<depend>sensor_msgs</depend>
<exec_depend>python_qt_binding</exec_depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
+4
View File
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/hik_camera_panel
[install]
install_scripts=$base/lib/hik_camera_panel
+28
View File
@@ -0,0 +1,28 @@
from setuptools import find_packages, setup
package_name = 'hik_camera_panel'
setup(
name=package_name,
version='1.0.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='khj',
maintainer_email='khj@example.com',
description=(
'hik_camera_ros2_driver의 노출/게인/ROI 파라미터를 슬라이더/직접입력으로 '
'실시간 조절하는 패널'
),
license='Apache-2.0',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'panel_node = hik_camera_panel.panel_node:main',
],
},
)
+109 -50
View File
@@ -3,96 +3,155 @@
# hik_camera_ros2_driver # hik_camera_ros2_driver
## Overview ## 개요
The `hik_camera_ros2_driver` package provides a ROS 2 driver for controlling and interfacing with Hikvision cameras. It supports functionalities such as camera initialization, parameter configuration, and image publishing. This package is intended for applications requiring reliable and configurable image data acquisition in a ROS 2 environment. `hik_camera_ros2_driver` 패키지는 Hikvision 카메라를 제어하고 연동하기 위한 ROS 2 드라이버다. 카메라 초기화, 파라미터 설정, 이미지 발행 등의 기능을 제공한다. ROS 2 환경에서 신뢰성 있고 설정 가능한 이미지 데이터 취득이 필요한 애플리케이션을 위한 패키지다.
### Executables ### 실행 파일
The package includes the `hik_camera_node`, which manages the camera and publishes image data along with camera information to ROS 2 topics. 이 패키지는 `hik_camera_node`를 포함하며, 카메라를 관리하고 이미지 데이터와 카메라 정보를 ROS 2 토픽으로 발행한다.
### Subscribed Topics ### 구독 토픽
None. 없음. (단, `sync_role: "slave"`일 때는 마스터 카메라의 `ae_exposure_time_us`/`ae_gain_db` 토픽을 구독한다 — 아래 `sync_role` 참조)
### Published Topics ### 발행 토픽
- `<camera_topic>` (sensor_msgs/msg/Image) - `<camera_topic>` (sensor_msgs/msg/Image)
- The image data captured by the Hikvision camera. - Hikvision 카메라가 캡처한 이미지 데이터.
- `<camera_topic>/camera_info` (sensor_msgs/msg/CameraInfo) - `<camera_topic>/camera_info` (sensor_msgs/msg/CameraInfo)
- Camera calibration information. - 카메라 캘리브레이션 정보.
### Parameters - `<camera_name>/ae_exposure_time_us` (std_msgs/msg/Float64)
- 매 프레임 실제로 적용된 노출시간[µs]. `sync_role`과 무관하게 항상 발행되므로, 다른 노드가 관찰용으로 구독해도 된다.
- `exposure_auto` (bool, default: `false`) - `<camera_name>/ae_gain_db` (std_msgs/msg/Float64)
- Enable continuous auto exposure. When `true`, the camera controls exposure automatically and `exposure_time` is ignored. - 매 프레임 실제로 적용된 게인[dB]. 위와 동일하게 항상 발행된다.
- `exposure_time` (double, default: `5000`) ### 파라미터
- Manual exposure time in microseconds. Used only when `exposure_auto` is `false`.
- `exposure_auto_target_brightness` (int, default: `128`, range: `0-255`) - `exposure_auto` (bool, 기본값: `false`)
- Target brightness for auto exposure. Active only when `exposure_auto` is `true`. Can be changed at runtime: - Continuous 자동 노출을 켠다. `true`면 카메라가 노출을 자동으로 제어하고 `exposure_time`은 무시된다.
- `exposure_time` (double, 기본값: `5000`)
- 수동 노출시간[µs]. `exposure_auto``false`일 때만 사용된다.
- `exposure_auto_target_brightness` (int, 기본값: `128`, 범위: `0-255`)
- 자동 노출의 목표 밝기. `exposure_auto``true`일 때만 적용된다. 런타임에 변경 가능:
```bash ```bash
ros2 param set /hik_camera_ros2_driver exposure_auto_target_brightness 100 ros2 param set /hik_camera_ros2_driver exposure_auto_target_brightness 100
``` ```
- `exposure_auto_min` (double, default: `100.0`) - `exposure_auto_min` (double, 기본값: `100.0`)
- Auto exposure lower limit in microseconds. Active only when `exposure_auto` is `true`. Can be changed at runtime. - 자동 노출 하한[µs]. `exposure_auto` `true`일 때만 적용된다. 런타임에 변경 가능.
- `exposure_auto_max` (double, default: `10000.0`) - `exposure_auto_max` (double, 기본값: `10000.0`)
- Auto exposure upper limit in microseconds. Active only when `exposure_auto` is `true`. Can be changed at runtime. - 자동 노출 상한[µs]. `exposure_auto` `true`일 때만 적용된다. 런타임에 변경 가능.
- `gain` (double) - `gain` (double)
- Manual gain. Auto gain is always disabled. Can be changed at runtime: - 수동 게인[dB]. `gain_auto`가 `false`일 때 사용된다. 런타임에 변경 가능:
```bash ```bash
ros2 param set /hik_camera_ros2_driver gain 2.0 ros2 param set /hik_camera_ros2_driver gain 2.0
``` ```
- `acquisition_frame_rate` (double, default: `165`) - `gain_auto` (bool, 기본값: `false`)
- The acquisition frame rate in hz for the camera. - 자동 게인을 켠다. `use_software_ae`가 `false`면 카메라 온보드 `Continuous` 모드로, `true`면 아래
소프트웨어 AE/AG 루프로 조절된다. 런타임에 변경 가능.
- `pixel_format` (string, default: `RGB8Packed`) - `gain_auto_max_db` (double, 기본값: `12.0`)
- The pixel format for the image data. Supported values: `Mono8`, `Mono10`, `Mono12`, `RGB8Packed`, `BGR8Packed`, `YUV422_YUYV_Packed`, `YUV422Packed`, `BayerRG8`, `BayerRG10`, `BayerRG10Packed`, `BayerRG12`, `BayerRG12Packed`. - 자동 게인 상한[dB]. `gain` 파라미터 자체의 하드웨어 범위와는 별개 값으로, 하드웨어 실측 상한
(이 카메라는 약 16.9 dB)보다 낮게 잡아 노이즈를 제한하는 용도.
- `adc_bit_depth` (string, default: `Bits_8`) - `use_software_ae` (bool, 기본값: `false`)
- The ADC bit depth for the camera. Supported values: `Bits_8`, `Bits_12`. - 카메라 온보드 `ExposureAuto`/`GainAuto`는 항상 캡처된 프레임 **전체**를 측광한다 — 이 SDK에는
캡처 AOI와 별개로 측광에만 쓰는 ROI를 지정하는 GenICam 노드가 없다. `true`면 온보드 auto를 끄고,
대신 `ae_roi_top_ratio`로 정한 ROI의 퍼센타일 밝기를 매 프레임 소프트웨어에서 직접 계산해
`ExposureTime`/`Gain`에 바로 써넣는다. 실제로 뭔가 조절되게 하려면 `exposure_auto`와/또는
`gain_auto`도 함께 `true`여야 한다 (아니면 `exposure_time`/`gain` 값에 고정됨). 런타임에 변경 가능.
- `use_sensor_data_qos` (bool, default: true) - `ae_roi_top_ratio` (double, 기본값: `0.0`)
- Whether to use the `sensor_data` QoS profile for image topic publication. - `use_software_ae`에서 측광 시 상단부터 제외할 프레임 높이 비율. `0.5`면 하단 절반만 측광한다
(예: 하늘 제외).
- `camera_name` (string, default: `camera`) - `ae_target_percentile` / `ae_target_dn` (기본값: `70.0` / `130`)
- The name of the camera for identification purposes. - 소프트웨어 AE/AG의 목표값: ROI 내 이 퍼센타일이 이 DN에 도달하도록 노출/게인을 조절한다.
- `frame_id` (string, default: `<camera_name>_optical_frame`) - `ae_saturation_percentile` / `ae_saturation_dn` (기본값: `98.0` / `245`)
- The frame_id assigned to the published image data. - 하드 제약: 위 목표에 도달하지 못했더라도, 이 퍼센타일이 이 DN을 넘어설 정도로는 절대 밝게 하지
않는다.
- `camera_topic` (string, default: `<camera_name>/image`) - `ae_step_gain` (double, 기본값: `0.5`)
- The topic name for publishing image and info data. - 소프트웨어 AE/AG 루프의 프레임당 보정 댐핑(0~1).
- `camera_info_url` (string, default: `package://hik_camera_ros2_driver/config/camera_info.yaml`) - `sync_role` (string, 기본값: `"independent"`)
- The URL for the camera calibration information file. - `"independent"`: 이 카메라가 자기 노출/게인을 스스로 결정한다 (기본값, 기존 동작과 동일).
- `"master"`: SDK가 매 프레임 주는 실제 적용값(AE 모드와 무관하게 항상 유효)을
`<camera_name>/ae_exposure_time_us`, `<camera_name>/ae_gain_db`로 발행한다.
- `"slave"`: 자기 자신의 자동 노출/게인 로직을 완전히 무시하고, `sync_master_camera_ns`가
발행하는 값을 그대로 적용한다. 카메라 여러 대 중 하나(예: 가운데 카메라)가 나머지의
노출/게인을 결정하게 하고 싶을 때 사용한다.
- `trigger_enable` (bool, default: `false`) - `sync_master_camera_ns` (string, 기본값: `""`)
- Enable hardware trigger mode (LINE0). When `true`, frame rate is controlled by the trigger signal. - `sync_role`이 `"slave"`일 때, 구독할 마스터 카메라의 `camera_name` (예: `"cam2"`).
- `use_trigger_timestamp` (bool, default: `false`) > **동작 요약:** 마스터(예: `cam2`)의 `exposure_auto`/`gain_auto`(및 `use_software_ae`)가 전부
- Use the shared memory timestamp written by the LiDAR driver instead of system time. > `false`이면, 마스터는 `exposure_time`/`gain`에 고정된 값으로 구동되고 그 고정값이 그대로
> 슬레이브(`cam1`, `cam3`)에 방송되어 **3대 전부 같은 고정 노출/게인**으로 구동된다.
> 마스터에서 `exposure_auto`/`gain_auto`(또는 `use_software_ae`)를 켜면, 마스터가 그때그때
> 계산한 실제 적용값이 프레임마다 방송되고 **슬레이브는 그 값을 그대로 추종**한다. 이때 슬레이브
> 자신의 `exposure_auto`/`gain_auto`/`use_software_ae` 값은 (설정되어 있더라도) 완전히
> 무시된다 — 노출/게인 자동 조절 여부는 오직 마스터 쪽 설정만으로 결정된다.
- `serial_number` (string, default: `""`) - `acquisition_frame_rate` (double, 기본값: `165`)
- Select a specific camera by serial number. If empty, the first detected camera is used. - 카메라의 취득 프레임률[Hz].
- `enable_interval_log` (bool, default: `false`) - `pixel_format` (string, 기본값: `RGB8Packed`)
- Print per-frame timestamp interval logs (`[TS]`). Also prints a rolling summary every 10 frames (avg / min / max / jitter). Can be toggled at runtime: - 이미지 데이터의 픽셀 포맷. 지원값: `Mono8`, `Mono10`, `Mono12`, `RGB8Packed`, `BGR8Packed`,
`YUV422_YUYV_Packed`, `YUV422Packed`, `BayerRG8`, `BayerRG10`, `BayerRG10Packed`, `BayerRG12`,
`BayerRG12Packed`.
- `adc_bit_depth` (string, 기본값: `Bits_8`)
- 카메라의 ADC 비트 심도. 지원값: `Bits_8`, `Bits_12`.
- `use_sensor_data_qos` (bool, 기본값: true)
- 이미지 토픽 발행에 `sensor_data` QoS 프로파일을 쓸지 여부.
- `camera_name` (string, 기본값: `camera`)
- 카메라 식별용 이름.
- `frame_id` (string, 기본값: `<camera_name>_optical_frame`)
- 발행되는 이미지 데이터에 붙는 frame_id.
- `camera_topic` (string, 기본값: `<camera_name>/image`)
- 이미지·정보 데이터를 발행할 토픽 이름.
- `camera_info_url` (string, 기본값: `package://hik_camera_ros2_driver/config/camera_info.yaml`)
- 카메라 캘리브레이션 정보 파일의 URL.
- `trigger_enable` (bool, 기본값: `false`)
- 하드웨어 트리거 모드(LINE0)를 켠다. `true`면 프레임률이 트리거 신호로 제어된다.
- `use_trigger_timestamp` (bool, 기본값: `false`)
- 시스템 시간 대신, LiDAR 드라이버가 기록한 공유 메모리 타임스탬프를 사용한다.
- `serial_number` (string, 기본값: `""`)
- 시리얼 번호로 특정 카메라를 선택한다. 비어 있으면 처음 검출된 카메라를 사용한다.
- `enable_interval_log` (bool, 기본값: `false`)
- 프레임별 타임스탬프 간격 로그(`[TS]`)를 출력한다. 10프레임마다 평균/최소/최대/지터 요약도
함께 출력한다. 런타임에 토글 가능:
```bash ```bash
ros2 param set /hik_camera_ros2_driver enable_interval_log true ros2 param set /hik_camera_ros2_driver enable_interval_log true
``` ```
### Usage ### 사용법
#### Installation #### 설치
To use this package, build it from source or include it in your ROS 2 workspace. Ensure that all dependencies are installed. You **don't** need to install the Hikvision camera SDK and include its libraries in your environment. 이 패키지를 사용하려면 소스로 빌드하거나 ROS 2 워크스페이스에 포함시키면 된다. 의존 패키지가 모두
설치되어 있는지 확인할 것. Hikvision 카메라 SDK를 별도로 설치하고 그 라이브러리를 환경에 포함시킬
필요는 **없다**.
```bash ```bash
mkdir -p ~/ros_ws/src mkdir -p ~/ros_ws/src
@@ -112,9 +171,9 @@ rosdep install -r --from-paths src --ignore-src --rosdistro $ROS_DISTRO -y
colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=Release colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=Release
``` ```
#### Run #### 실행
You can use the provided launch file for starting the camera node with default or custom parameters: 제공된 launch 파일로 기본값 또는 커스텀 파라미터를 사용해 카메라 노드를 실행할 수 있다:
```bash ```bash
ros2 launch hik_camera_ros2_driver hik_camera_launch.py ros2 launch hik_camera_ros2_driver hik_camera_launch.py
@@ -1,8 +1,8 @@
/hik_camera_cam1: /hik_camera_cam1:
ros__parameters: ros__parameters:
camera_info_url: "package://hik_camera_ros2_driver/config/camera_info_cam1.yaml" camera_info_url: "package://hik_camera_ros2_driver/config/camera_info_cam1.yaml"
pixel_format: "BayerRG8" # Recommended Option: "RGB8Packed", "BayerRG8" pixel_format: "BayerRG8" # Recommended Option: "RGB8Packed", "BayerRG8"
adc_bit_depth: "Bits_8" # If using "BayerRG8", <adc_bit_depth> must be set as "Bits_8"; otherwise, it can be "Bits_8" or "Bits_12" adc_bit_depth: "Bits_8" # If using "BayerRG8", <adc_bit_depth> must be set as "Bits_8"; otherwise, it can be "Bits_8" or "Bits_12"
use_sensor_data_qos: false use_sensor_data_qos: false
camera_name: "cam1" camera_name: "cam1"
# frame_id: "optical_frame" # If not set, it will be set as <camera_name>_optical_frame # frame_id: "optical_frame" # If not set, it will be set as <camera_name>_optical_frame
@@ -37,19 +37,36 @@
dev_clock_recalib_interval_sec: 2.0 dev_clock_recalib_interval_sec: 2.0
# 프리런 모드(trigger_enable: false)일 때만 적용 # 프리런 모드(trigger_enable: false)일 때만 적용
acquisition_frame_rate: 10.0 # Unit: Hz acquisition_frame_rate: 10.0 # Unit: Hz
exposure_auto: true # 자동 노출인 경우
exposure_auto_target_brightness: 110 exposure_auto: false
exposure_auto_min: 100.0 exposure_auto_target_brightness: 100
exposure_auto_min: 50.0
exposure_auto_max: 5000.0 exposure_auto_max: 5000.0
exposure_time: 5000 # Unit: us
gain: 12.0 # Range: 0.0 ~ 16.9, Unit: dB # 수동 노출인 경우
exposure_time: 50 # Unit: us
gain: 0.0 # Range: 0.0 ~ 16.9, Unit: dB
# 노출/게인 동기화 — cam2(가운데)를 마스터로 따라감. exposure_auto/gain_auto/
# use_software_ae는 슬레이브에서는 무시된다 (마스터가 ae_exposure_time_us/ae_gain_db로
# 방송하는 실제 적용값을 그대로 ExposureTime/Gain에 적용).
sync_role: "slave"
sync_master_camera_ns: "cam2"
# 화이트밸런스 — cam1/cam2 겹치는 영역 색감을 맞추기 위해 두 카메라에 동일한 # 화이트밸런스 — cam1/cam2 겹치는 영역 색감을 맞추기 위해 두 카메라에 동일한
# 수동 R/G/B 비율을 고정한다 (2026-07-15, 두 카메라 Continuous AWB 수렴값의 평균). # 수동 R/G/B 비율을 고정한다 (2026-07-15, 두 카메라 Continuous AWB 수렴값의 평균).
# cam1 실측: R=1414 G=1024 B=2060 / cam2 실측: R=1408 G=1024 B=2184 → 평균 적용 # cam1 실측: R=1414 G=1024 B=2060 / cam2 실측: R=1408 G=1024 B=2184 → 평균 적용
# 2대의 카메라가 아닌 3대의 카메라에 대한 화이트 벨런스 로직으로 개선이 필요함
# 조리개, 초점링 조절을 위해 화이트 벨런스를 오프함
# 오프시 1024라는 수치로 조절하면 됨
balance_white_auto: false balance_white_auto: false
balance_ratio_red: 1411 balance_ratio_red: 1024
balance_ratio_green: 1024 balance_ratio_green: 1024
balance_ratio_blue: 2122 balance_ratio_blue: 1024
# balance_ratio_red: 1411
# balance_ratio_green: 1024
# balance_ratio_blue: 2122
@@ -1,8 +1,8 @@
/hik_camera_cam2: /hik_camera_cam2:
ros__parameters: ros__parameters:
camera_info_url: "package://hik_camera_ros2_driver/config/camera_info_cam2.yaml" camera_info_url: "package://hik_camera_ros2_driver/config/camera_info_cam2.yaml"
pixel_format: "BayerRG8" # Recommended Option: "RGB8Packed", "BayerRG8" pixel_format: "BayerRG8" # Recommended Option: "RGB8Packed", "BayerRG8"
adc_bit_depth: "Bits_8" # If using "BayerRG8", <adc_bit_depth> must be set as "Bits_8"; otherwise, it can be "Bits_8" or "Bits_12" adc_bit_depth: "Bits_8" # If using "BayerRG8", <adc_bit_depth> must be set as "Bits_8"; otherwise, it can be "Bits_8" or "Bits_12"
use_sensor_data_qos: false use_sensor_data_qos: false
camera_name: "cam2" camera_name: "cam2"
# frame_id: "optical_frame" # If not set, it will be set as <camera_name>_optical_frame # frame_id: "optical_frame" # If not set, it will be set as <camera_name>_optical_frame
@@ -37,19 +37,51 @@
dev_clock_recalib_interval_sec: 2.0 dev_clock_recalib_interval_sec: 2.0
# 프리런 모드(trigger_enable: false)일 때만 적용 # 프리런 모드(trigger_enable: false)일 때만 적용
acquisition_frame_rate: 10.0 # Unit: Hz acquisition_frame_rate: 10.0 # Unit: Hz
exposure_auto: true # 자동 노출인 경우
exposure_auto_target_brightness: 128 exposure_auto: false
exposure_auto_min: 100.0 exposure_auto_target_brightness: 100
exposure_auto_min: 50.0
exposure_auto_max: 5000.0 exposure_auto_max: 5000.0
exposure_time: 5000 # Unit: us
gain: 15.0 # Range: 0.0 ~ 16.9, Unit: dB # 수동 노출인 경우
exposure_time: 50 # Unit: us
gain: 0.0 # Range: 0.0 ~ 16.9, Unit: dB
# 게인 자동 조절 — 조리개/초점 확정 전이라 아직 꺼둠 (수동 게인 유지)
gain_auto: false
gain_auto_max_db: 12.0 # 자동 게인 켤 때 상한. 하드웨어 실측 상한(~16.9dB)보다 낮게 제한
# 소프트웨어 AE/AG — 온보드 auto는 풀프레임만 측광 가능해서(하늘 포함 시 바닥이
# 어두워지는 문제, camera_exposure_design_notes.md 4절) 하단부만 측광하려면 이 경로가
# 필요함. 지금은 꺼둠 — 조리개/초점 확정 후 켤 것.
use_software_ae: false
ae_roi_top_ratio: 0.5 # 켤 경우 상단 50%(하늘) 제외하고 측광
ae_target_percentile: 70.0
ae_target_dn: 130
ae_saturation_percentile: 98.0
ae_saturation_dn: 245
ae_step_gain: 0.5
# 노출/게인 동기화 — cam2(가운데)가 마스터. 실제 적용된 노출/게인을
# ae_exposure_time_us / ae_gain_db 토픽으로 매 프레임 방송한다. 지금은
# exposure_auto/gain_auto가 꺼져 있어 위 수동 고정값을 그대로 방송하므로,
# cam1/cam3가 이 값을 따라가서 3대가 항상 같은 노출/게인을 쓰게 된다.
sync_role: "master"
# 화이트밸런스 — cam1/cam2 겹치는 영역 색감을 맞추기 위해 두 카메라에 동일한 # 화이트밸런스 — cam1/cam2 겹치는 영역 색감을 맞추기 위해 두 카메라에 동일한
# 수동 R/G/B 비율을 고정한다 (2026-07-15, 두 카메라 Continuous AWB 수렴값의 평균). # 수동 R/G/B 비율을 고정한다 (2026-07-15, 두 카메라 Continuous AWB 수렴값의 평균).
# cam1 실측: R=1414 G=1024 B=2060 / cam2 실측: R=1408 G=1024 B=2184 → 평균 적용 # cam1 실측: R=1414 G=1024 B=2060 / cam2 실측: R=1408 G=1024 B=2184 → 평균 적용
# 2대의 카메라가 아닌 3대의 카메라에 대한 화이트 벨런스 로직으로 개선이 필요함
# 조리개, 초점링 조절을 위해 화이트 벨런스를 오프함
# 오프시 1024라는 수치로 조절하면 됨
balance_white_auto: false balance_white_auto: false
balance_ratio_red: 1411 balance_ratio_red: 1024
balance_ratio_green: 1024 balance_ratio_green: 1024
balance_ratio_blue: 2122 balance_ratio_blue: 1024
# balance_ratio_red: 1411
# balance_ratio_green: 1024
# balance_ratio_blue: 2122
@@ -1,8 +1,8 @@
/hik_camera_cam3: /hik_camera_cam3:
ros__parameters: ros__parameters:
camera_info_url: "package://hik_camera_ros2_driver/config/camera_info_cam3.yaml" camera_info_url: "package://hik_camera_ros2_driver/config/camera_info_cam3.yaml"
pixel_format: "BayerRG8" # Recommended Option: "RGB8Packed", "BayerRG8" pixel_format: "BayerRG8" # Recommended Option: "RGB8Packed", "BayerRG8"
adc_bit_depth: "Bits_8" # If using "BayerRG8", <adc_bit_depth> must be set as "Bits_8"; otherwise, it can be "Bits_8" or "Bits_12" adc_bit_depth: "Bits_8" # If using "BayerRG8", <adc_bit_depth> must be set as "Bits_8"; otherwise, it can be "Bits_8" or "Bits_12"
use_sensor_data_qos: false use_sensor_data_qos: false
camera_name: "cam3" camera_name: "cam3"
# frame_id: "optical_frame" # If not set, it will be set as <camera_name>_optical_frame # frame_id: "optical_frame" # If not set, it will be set as <camera_name>_optical_frame
@@ -37,16 +37,36 @@
dev_clock_recalib_interval_sec: 2.0 dev_clock_recalib_interval_sec: 2.0
# 프리런 모드(trigger_enable: false)일 때만 적용 # 프리런 모드(trigger_enable: false)일 때만 적용
acquisition_frame_rate: 10.0 # Unit: Hz acquisition_frame_rate: 10.0 # Unit: Hz
# cam1/cam2와 동일하게 맞춤 (밝기 차이 최소화) # 자동 노출인 경우
exposure_auto: true exposure_auto: false
exposure_auto_target_brightness: 145 exposure_auto_target_brightness: 100
exposure_auto_min: 100.0 exposure_auto_min: 50.0
exposure_auto_max: 8000.0 exposure_auto_max: 5000.0
exposure_time: 5000 # Unit: us
gain: 12.0 # Range: 0.0 ~ 16.9, Unit: dB
balance_ratio_red: 1411 # 수동 노출인 경우
exposure_time: 50 # Unit: us
gain: 0.0 # Range: 0.0 ~ 16.9, Unit: dB
# 노출/게인 동기화 — cam2(가운데)를 마스터로 따라감. exposure_auto/gain_auto/
# use_software_ae는 슬레이브에서는 무시된다 (마스터가 ae_exposure_time_us/ae_gain_db로
# 방송하는 실제 적용값을 그대로 ExposureTime/Gain에 적용).
sync_role: "slave"
sync_master_camera_ns: "cam2"
# 화이트밸런스 — cam1/cam2 겹치는 영역 색감을 맞추기 위해 두 카메라에 동일한
# 수동 R/G/B 비율을 고정한다 (2026-07-15, 두 카메라 Continuous AWB 수렴값의 평균).
# cam1 실측: R=1414 G=1024 B=2060 / cam2 실측: R=1408 G=1024 B=2184 → 평균 적용
# 2대의 카메라가 아닌 3대의 카메라에 대한 화이트 벨런스 로직으로 개선이 필요함
# 조리개, 초점링 조절을 위해 화이트 벨런스를 오프함
# 오프시 1024라는 수치로 조절하면 됨
balance_white_auto: false
balance_ratio_red: 1024
balance_ratio_green: 1024 balance_ratio_green: 1024
balance_ratio_blue: 2122 balance_ratio_blue: 1024
# balance_ratio_red: 1411
# balance_ratio_green: 1024
# balance_ratio_blue: 2122
+1
View File
@@ -13,6 +13,7 @@
<depend>rclcpp</depend> <depend>rclcpp</depend>
<depend>rclcpp_components</depend> <depend>rclcpp_components</depend>
<depend>sensor_msgs</depend> <depend>sensor_msgs</depend>
<depend>std_msgs</depend>
<depend>image_transport</depend> <depend>image_transport</depend>
<depend>image_transport_plugins</depend> <depend>image_transport_plugins</depend>
<depend>camera_info_manager</depend> <depend>camera_info_manager</depend>
@@ -1,7 +1,9 @@
#include <algorithm>
#include <cmath> #include <cmath>
#include <deque> #include <deque>
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector>
#include <unistd.h> #include <unistd.h>
#include <fcntl.h> #include <fcntl.h>
#include <sys/mman.h> #include <sys/mman.h>
@@ -11,6 +13,7 @@
#include "image_transport/image_transport.hpp" #include "image_transport/image_transport.hpp"
#include "rclcpp/logging.hpp" #include "rclcpp/logging.hpp"
#include "rclcpp/utilities.hpp" #include "rclcpp/utilities.hpp"
#include "std_msgs/msg/float64.hpp"
namespace hik_camera_ros2_driver namespace hik_camera_ros2_driver
{ {
@@ -32,6 +35,7 @@ public:
declareParameters(); declareParameters();
startCamera(); startCamera();
initSharedMemory(); initSharedMemory();
initSync();
params_callback_handle_ = this->add_on_set_parameters_callback( params_callback_handle_ = this->add_on_set_parameters_callback(
std::bind(&HikCameraRos2DriverNode::dynamicParametersCallback, this, std::placeholders::_1)); std::bind(&HikCameraRos2DriverNode::dynamicParametersCallback, this, std::placeholders::_1));
@@ -172,6 +176,16 @@ private:
RCLCPP_INFO(this->get_logger(), "Acquisition frame rate: %f", acquisition_frame_rate); RCLCPP_INFO(this->get_logger(), "Acquisition frame rate: %f", acquisition_frame_rate);
} }
// 노출/게인 동기화 (마스터-슬레이브) — exposure/gain 블록보다 먼저 선언해야
// applyExposureMode()/applyGainMode()가 이 값을 보고 분기할 수 있음.
// independent: 이 카메라 단독으로 노출/게인 결정 (기존 동작, 기본값)
// master: 실제 적용된 노출/게인을 ae_exposure_time_us / ae_gain_db 토픽으로 발행
// slave: sync_master_camera_ns 의 master 토픽을 구독해 그 값을 그대로 적용
param_desc.description = "노출/게인 동기화 역할: independent / master / slave";
sync_role_ = this->declare_parameter("sync_role", std::string("independent"));
param_desc.description = "sync_role=slave일 때 구독할 마스터 카메라의 camera_name (예: \"cam2\")";
sync_master_camera_ns_ = this->declare_parameter("sync_master_camera_ns", std::string(""));
// Exposure — 모든 파라미터를 항상 선언하여 런타임 모드 전환 지원 // Exposure — 모든 파라미터를 항상 선언하여 런타임 모드 전환 지원
MV_CC_GetFloatValue(camera_handle_, "ExposureTime", &f_value); MV_CC_GetFloatValue(camera_handle_, "ExposureTime", &f_value);
exposure_auto_ = this->declare_parameter("exposure_auto", false); exposure_auto_ = this->declare_parameter("exposure_auto", false);
@@ -193,17 +207,65 @@ private:
param_desc.description = "Manual exposure time in microseconds"; param_desc.description = "Manual exposure time in microseconds";
exposure_time_ = this->declare_parameter("exposure_time", 5000, param_desc); exposure_time_ = this->declare_parameter("exposure_time", 5000, param_desc);
// 소프트웨어 AE/AG — 카메라 온보드 ExposureAuto/GainAuto는 항상 풀프레임을 측광한다.
// 이 SDK(hikSDK/include)에는 캡처 AOI(MV_CC_Set/GetAOIoffsetX/Y, 이미지 자체를 자름)와
// 별개로 "측광에만 쓰는 ROI"를 지정하는 GenICam 노드가 없다 (AutoFunctionAOI 계열
// 탐색했으나 없음). 하늘을 제외한 하단부만 측광하려면(4번 질문, camera_exposure_design_notes.md
// 4절) 온보드 auto를 끄고 소프트웨어에서 직접 ExposureTime/Gain을 계산해 써야 한다.
param_desc.description =
"true면 온보드 Continuous auto 대신, ae_roi_top_ratio로 제한한 영역의 퍼센타일 밝기로 "
"노출/게인을 소프트웨어에서 직접 계산해 적용한다";
use_software_ae_ = this->declare_parameter("use_software_ae", false);
param_desc.description =
"측광에서 제외할 프레임 상단 비율 (0.5 = 하단 절반만 측광, 하늘 제외 용도)";
ae_roi_top_ratio_ = this->declare_parameter("ae_roi_top_ratio", 0.0);
param_desc.description = "측광 ROI 내 목표 퍼센타일 (0~100)";
ae_target_percentile_ = this->declare_parameter("ae_target_percentile", 70.0);
param_desc.description = "ae_target_percentile 지점의 목표 DN (0~255)";
ae_target_dn_ = this->declare_parameter("ae_target_dn", 130);
param_desc.description = "포화 방지용 퍼센타일 (0~100)";
ae_saturation_percentile_ = this->declare_parameter("ae_saturation_percentile", 98.0);
param_desc.description = "ae_saturation_percentile 지점이 이 DN을 넘지 않도록 제한";
ae_saturation_dn_ = this->declare_parameter("ae_saturation_dn", 245);
param_desc.description = "소프트웨어 AE/AG 1프레임당 보정 비율 (0~1, 클수록 빨리 수렴하되 진동 위험)";
ae_step_gain_ = this->declare_parameter("ae_step_gain", 0.5);
if (use_software_ae_) {
soft_ae_exposure_us_ = static_cast<double>(exposure_time_);
}
applyExposureMode(); applyExposureMode();
// Gain (manual only — GainAuto=Off) // Gain
MV_CC_SetEnumValue(camera_handle_, "GainAuto", 0); param_desc.description =
"true면 게인을 자동 조절한다 (use_software_ae=false: 온보드 Continuous, "
"use_software_ae=true: 위 소프트웨어 AE/AG 루프가 함께 조절)";
gain_auto_ = this->declare_parameter("gain_auto", false);
param_desc.description = "Gain"; param_desc.description = "Gain";
MV_CC_GetFloatValue(camera_handle_, "Gain", &f_value); MV_CC_GetFloatValue(camera_handle_, "Gain", &f_value);
param_desc.integer_range[0].from_value = static_cast<int64_t>(f_value.fMin); 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); param_desc.integer_range[0].to_value = static_cast<int64_t>(f_value.fMax);
double gain = this->declare_parameter("gain", f_value.fCurValue, param_desc); gain_ = this->declare_parameter("gain", static_cast<double>(f_value.fCurValue), param_desc);
MV_CC_SetFloatValue(camera_handle_, "Gain", gain);
RCLCPP_INFO(this->get_logger(), "Gain: %f", gain); param_desc.description =
"자동 게인 상한 [dB] — 하드웨어 실측 상한(카메라별 상이, 대략 16.9dB)보다 낮게 잡아서 "
"노이즈를 제한하는 용도. gain 파라미터 자체의 상한(위 range)과는 별개 값";
param_desc.integer_range[0].from_value = 0;
param_desc.integer_range[0].to_value = static_cast<int64_t>(f_value.fMax);
gain_auto_max_db_ = this->declare_parameter("gain_auto_max_db", 12.0, param_desc);
if (use_software_ae_) {
soft_ae_gain_db_ = gain_;
}
applyGainMode();
// White balance — 기본은 수동(Off) + 고정 R/G/B 비율. // White balance — 기본은 수동(Off) + 고정 R/G/B 비율.
// 이유: 카메라별로 독립 Continuous AWB를 켜두면 두 카메라가 서로 다른 화각을 보고 // 이유: 카메라별로 독립 Continuous AWB를 켜두면 두 카메라가 서로 다른 화각을 보고
@@ -320,8 +382,65 @@ private:
RCLCPP_INFO(this->get_logger(), "Shared memory timestamp enabled: %s", path.c_str()); RCLCPP_INFO(this->get_logger(), "Shared memory timestamp enabled: %s", path.c_str());
} }
// 노출/게인 마스터-슬레이브 동기화. 카메라별 실제 적용값(fExposureTime/fGain,
// MV_FRAME_OUT_INFO_EX에 매 프레임 이미 들어있음 — captureLoop 참조)을 퍼블리시/구독한다.
// publisher는 role과 무관하게 항상 만들어서, 굳이 master로 지정하지 않아도 다른 노드가
// 관찰용으로 구독할 수 있게 한다.
void initSync()
{
ae_exposure_pub_ = this->create_publisher<std_msgs::msg::Float64>(
camera_name_ + "/ae_exposure_time_us", 10);
ae_gain_pub_ = this->create_publisher<std_msgs::msg::Float64>(
camera_name_ + "/ae_gain_db", 10);
if (sync_role_ == "slave") {
if (sync_master_camera_ns_.empty()) {
RCLCPP_ERROR(this->get_logger(),
"sync_role=slave 인데 sync_master_camera_ns가 비어 있음 — 마스터를 구독할 수 없음");
return;
}
const std::string prefix = "/" + sync_master_camera_ns_;
ae_exposure_sub_ = this->create_subscription<std_msgs::msg::Float64>(
prefix + "/ae_exposure_time_us", 10,
[this](const std_msgs::msg::Float64::SharedPtr msg) {
exposure_time_ = static_cast<int>(std::lround(msg->data));
MV_CC_SetFloatValue(camera_handle_, "ExposureTime", static_cast<float>(msg->data));
});
ae_gain_sub_ = this->create_subscription<std_msgs::msg::Float64>(
prefix + "/ae_gain_db", 10,
[this](const std_msgs::msg::Float64::SharedPtr msg) {
gain_ = msg->data;
MV_CC_SetFloatValue(camera_handle_, "Gain", static_cast<float>(msg->data));
});
RCLCPP_INFO(this->get_logger(), "Sync: slave of %s", prefix.c_str());
} else if (sync_role_ == "master") {
RCLCPP_INFO(this->get_logger(),
"Sync: master (publishing %s/ae_exposure_time_us, %s/ae_gain_db)",
camera_name_.c_str(), camera_name_.c_str());
}
}
void applyExposureMode() void applyExposureMode()
{ {
if (sync_role_ == "slave") {
// 슬레이브는 마스터가 보내주는 값을 그대로 적용할 뿐, 자체 auto 로직을 돌리지 않는다
// (initSync()의 구독 콜백이 실제 적용을 담당). 여기서는 초기값만 세팅.
MV_CC_SetEnumValue(camera_handle_, "ExposureAuto", 0);
MV_CC_SetFloatValue(camera_handle_, "ExposureTime", static_cast<float>(exposure_time_));
RCLCPP_INFO(this->get_logger(), "Exposure: slave mode, waiting for master sync");
return;
}
if (use_software_ae_) {
// 온보드 auto는 ROI 측광을 지원하지 않으므로 항상 Off로 두고, 실제 조절은
// runSoftwareAeStep()이 매 프레임 ExposureTime을 직접 써서 수행한다.
MV_CC_SetEnumValue(camera_handle_, "ExposureAuto", 0);
MV_CC_SetFloatValue(camera_handle_, "ExposureTime", static_cast<float>(soft_ae_exposure_us_));
RCLCPP_INFO(this->get_logger(),
"Software AE: %s, ROI top-exclude=%.2f, target p%.0f=DN%d, range=[%.0f, %.0f] us",
exposure_auto_ ? "ON" : "OFF (fixed)", ae_roi_top_ratio_,
ae_target_percentile_, ae_target_dn_, exposure_auto_min_, exposure_auto_max_);
return;
}
if (exposure_auto_) { if (exposure_auto_) {
MV_CC_SetEnumValue(camera_handle_, "ExposureAuto", 2); // Continuous MV_CC_SetEnumValue(camera_handle_, "ExposureAuto", 2); // Continuous
MV_CC_SetIntValue(camera_handle_, "AutoTargetBrightness", MV_CC_SetIntValue(camera_handle_, "AutoTargetBrightness",
@@ -340,6 +459,45 @@ private:
} }
} }
void applyGainMode()
{
if (sync_role_ == "slave") {
MV_CC_SetEnumValue(camera_handle_, "GainAuto", 0);
MV_CC_SetFloatValue(camera_handle_, "Gain", static_cast<float>(gain_));
RCLCPP_INFO(this->get_logger(), "Gain: slave mode, waiting for master sync");
return;
}
if (use_software_ae_) {
MV_CC_SetEnumValue(camera_handle_, "GainAuto", 0);
MV_CC_SetFloatValue(camera_handle_, "Gain", static_cast<float>(soft_ae_gain_db_));
RCLCPP_INFO(this->get_logger(),
"Software AG: %s, range=[0, %.1f] dB", gain_auto_ ? "ON" : "OFF (fixed)",
gain_auto_max_db_);
return;
}
if (gain_auto_) {
MV_CC_SetEnumValue(camera_handle_, "GainAuto", 2); // Continuous
// AutoGainLowerLimit/UpperLimit은 AutoExposureTimeLowerLimit/UpperLimit과 같은 명명
// 규칙을 따른다고 가정한 값이다 — 이 카메라의 실제 GenICam 노드맵에서 검증된 적은
// 없음. 실패 시 아래 WARN 로그로 바로 드러나므로, 뜨면 MVS Feature Tree에서
// 정확한 노드명을 확인해서 고칠 것.
int status_lo = MV_CC_SetFloatValue(camera_handle_, "AutoGainLowerLimit", 0.0f);
int status_hi = MV_CC_SetFloatValue(
camera_handle_, "AutoGainUpperLimit", static_cast<float>(gain_auto_max_db_));
if (status_lo != MV_OK || status_hi != MV_OK) {
RCLCPP_WARN(this->get_logger(),
"Failed to set AutoGainLowerLimit/UpperLimit (status=0x%x/0x%x) — node name may not "
"match this camera's GenICam map, verify via MVS Feature Tree",
status_lo, status_hi);
}
RCLCPP_INFO(this->get_logger(), "Auto gain: ON, range=[0, %.1f] dB", gain_auto_max_db_);
} else {
MV_CC_SetEnumValue(camera_handle_, "GainAuto", 0); // Off
MV_CC_SetFloatValue(camera_handle_, "Gain", static_cast<float>(gain_));
RCLCPP_INFO(this->get_logger(), "Manual gain: %.1f dB", gain_);
}
}
// 2026-07-29: nTriggerIndex는 이 카메라(MV-CS016-10UC USB3)에서 항상 0으로 고정되어 // 2026-07-29: nTriggerIndex는 이 카메라(MV-CS016-10UC USB3)에서 항상 0으로 고정되어
// 실사용 불가로 확인됨 (실기기 로그로 검증). 대신 nDevTimeStampHigh/Low(카메라 자체 // 실사용 불가로 확인됨 (실기기 로그로 검증). 대신 nDevTimeStampHigh/Low(카메라 자체
// 자유구동 하드웨어 클럭)는 프레임마다 정상적으로 증가하는 것을 로그로 확인했다. // 자유구동 하드웨어 클럭)는 프레임마다 정상적으로 증가하는 것을 로그로 확인했다.
@@ -479,6 +637,100 @@ private:
return this->now(); return this->now();
} }
// ROI 제한 퍼센타일 측광으로 노출/게인을 계산해 직접 적용한다 (설계노트 4.2절 방식).
// 밝게 해야 할 때는 노출을 exposure_auto_max_까지 먼저 늘리고, 그래도 모자라면 게인을
// 쓴다. 어둡게 해야 할 때는 반대로 게인을 먼저 줄이고, 그래도 남으면 노출을 줄인다
// (게인이 노이즈 비용이 있으므로 항상 마지막 수단으로 남겨두는 우선순위).
void runSoftwareAeStep()
{
const int width = static_cast<int>(image_msg_.width);
const int height = static_cast<int>(image_msg_.height);
const double top_ratio = std::min(0.95, std::max(0.0, ae_roi_top_ratio_));
const int row_start = static_cast<int>(height * top_ratio);
if (row_start >= height || width <= 0) {
return;
}
constexpr int kStride = 4; // 매 프레임 전체 픽셀을 다 볼 필요 없음 — 서브샘플링
std::vector<uint8_t> luma;
luma.reserve((static_cast<size_t>(width) / kStride + 1) *
(static_cast<size_t>(height - row_start) / kStride + 1));
const uint8_t * data = image_msg_.data.data();
const size_t step = image_msg_.step; // width * 3 (rgb8)
for (int y = row_start; y < height; y += kStride) {
const uint8_t * row = data + static_cast<size_t>(y) * step;
for (int x = 0; x < width; x += kStride) {
const uint8_t * px = row + static_cast<size_t>(x) * 3;
luma.push_back(static_cast<uint8_t>(
(static_cast<int>(px[0]) + px[1] + px[2]) / 3));
}
}
if (luma.empty()) {
return;
}
std::sort(luma.begin(), luma.end());
auto percentile_dn = [&luma](double p) {
double clamped = std::min(100.0, std::max(0.0, p));
size_t idx = static_cast<size_t>(clamped / 100.0 * static_cast<double>(luma.size() - 1));
return static_cast<double>(luma[idx]);
};
const double p_target = percentile_dn(ae_target_percentile_);
const double p_sat = percentile_dn(ae_saturation_percentile_);
double delta_stop = std::log2(static_cast<double>(ae_target_dn_) / std::max(p_target, 1.0));
const double delta_stop_sat_limit =
std::log2(static_cast<double>(ae_saturation_dn_) / std::max(p_sat, 1.0));
delta_stop = std::min(delta_stop, delta_stop_sat_limit); // 포화 제약이 우선
delta_stop *= ae_step_gain_;
double remaining = delta_stop;
if (remaining > 0.0 && exposure_auto_) {
const double t_stop_avail = std::log2(exposure_auto_max_ / soft_ae_exposure_us_);
const double t_stop_used = std::min(remaining, std::max(0.0, t_stop_avail));
soft_ae_exposure_us_ *= std::pow(2.0, t_stop_used);
remaining -= t_stop_used;
if (gain_auto_ && remaining > 0.0) {
const double g_stop_avail = (gain_auto_max_db_ - soft_ae_gain_db_) / 6.02;
const double g_stop_used = std::min(remaining, std::max(0.0, g_stop_avail));
soft_ae_gain_db_ += g_stop_used * 6.02;
}
} else if (remaining < 0.0) {
double need = -remaining;
if (gain_auto_) {
const double g_stop_avail = soft_ae_gain_db_ / 6.02;
const double g_stop_used = std::min(need, std::max(0.0, g_stop_avail));
soft_ae_gain_db_ -= g_stop_used * 6.02;
need -= g_stop_used;
}
if (need > 0.0 && exposure_auto_) {
const double t_stop_avail = std::log2(soft_ae_exposure_us_ / exposure_auto_min_);
const double t_stop_used = std::min(need, std::max(0.0, t_stop_avail));
soft_ae_exposure_us_ /= std::pow(2.0, t_stop_used);
}
}
soft_ae_exposure_us_ =
std::min(exposure_auto_max_, std::max(exposure_auto_min_, soft_ae_exposure_us_));
soft_ae_gain_db_ = std::min(gain_auto_max_db_, std::max(0.0, soft_ae_gain_db_));
MV_CC_SetFloatValue(camera_handle_, "ExposureTime", static_cast<float>(soft_ae_exposure_us_));
MV_CC_SetFloatValue(camera_handle_, "Gain", static_cast<float>(soft_ae_gain_db_));
}
// 실제 적용된 노출/게인(chunk 없이도 SDK가 매 프레임 넘겨주는 값)을 브로드캐스트한다.
// 온보드 auto / 소프트웨어 AE / 수동 고정 어느 모드든 상관없이 "진짜 적용값"을 그대로 보냄.
void publishAeState(const MV_FRAME_OUT_INFO_EX & frame_info)
{
std_msgs::msg::Float64 exp_msg;
exp_msg.data = static_cast<double>(frame_info.fExposureTime);
ae_exposure_pub_->publish(exp_msg);
std_msgs::msg::Float64 gain_msg;
gain_msg.data = static_cast<double>(frame_info.fGain);
ae_gain_pub_->publish(gain_msg);
}
void captureLoop() void captureLoop()
{ {
MV_FRAME_OUT out_frame; MV_FRAME_OUT out_frame;
@@ -539,6 +791,13 @@ private:
camera_info_msg_.header = image_msg_.header; camera_info_msg_.header = image_msg_.header;
camera_pub_.publish(image_msg_, camera_info_msg_); camera_pub_.publish(image_msg_, camera_info_msg_);
if (sync_role_ != "slave" && use_software_ae_ && (exposure_auto_ || gain_auto_)) {
runSoftwareAeStep();
}
if (sync_role_ == "master") {
publishAeState(out_frame.stFrameInfo);
}
MV_CC_FreeImageBuffer(camera_handle_, &out_frame); MV_CC_FreeImageBuffer(camera_handle_, &out_frame);
static auto last_log_time = std::chrono::steady_clock::now(); static auto last_log_time = std::chrono::steady_clock::now();
@@ -593,6 +852,17 @@ private:
} else if (name == "exposure_auto") { } else if (name == "exposure_auto") {
exposure_auto_ = param.as_bool(); exposure_auto_ = param.as_bool();
applyExposureMode(); applyExposureMode();
} else if (name == "gain_auto") {
gain_auto_ = param.as_bool();
applyGainMode();
} else if (name == "use_software_ae") {
use_software_ae_ = param.as_bool();
if (use_software_ae_) {
soft_ae_exposure_us_ = static_cast<double>(exposure_time_);
soft_ae_gain_db_ = gain_;
}
applyExposureMode();
applyGainMode();
} else { } else {
result.successful = false; result.successful = false;
result.reason = "Unknown parameter: " + name; result.reason = "Unknown parameter: " + name;
@@ -600,7 +870,10 @@ private:
} }
} else if (type == rclcpp::ParameterType::PARAMETER_DOUBLE) { } else if (type == rclcpp::ParameterType::PARAMETER_DOUBLE) {
if (name == "gain") { if (name == "gain") {
status = MV_CC_SetFloatValue(camera_handle_, "Gain", param.as_double()); gain_ = param.as_double();
if (!gain_auto_ && !use_software_ae_) {
status = MV_CC_SetFloatValue(camera_handle_, "Gain", static_cast<float>(gain_));
}
} else if (name == "exposure_auto_min") { } else if (name == "exposure_auto_min") {
exposure_auto_min_ = param.as_double(); exposure_auto_min_ = param.as_double();
if (exposure_auto_) { if (exposure_auto_) {
@@ -613,6 +886,20 @@ private:
status = MV_CC_SetIntValue(camera_handle_, "AutoExposureTimeUpperLimit", status = MV_CC_SetIntValue(camera_handle_, "AutoExposureTimeUpperLimit",
static_cast<unsigned int>(exposure_auto_max_)); static_cast<unsigned int>(exposure_auto_max_));
} }
} else if (name == "gain_auto_max_db") {
gain_auto_max_db_ = param.as_double();
if (gain_auto_ && !use_software_ae_) {
status = MV_CC_SetFloatValue(
camera_handle_, "AutoGainUpperLimit", static_cast<float>(gain_auto_max_db_));
}
} else if (name == "ae_roi_top_ratio") {
ae_roi_top_ratio_ = param.as_double();
} else if (name == "ae_target_percentile") {
ae_target_percentile_ = param.as_double();
} else if (name == "ae_saturation_percentile") {
ae_saturation_percentile_ = param.as_double();
} else if (name == "ae_step_gain") {
ae_step_gain_ = param.as_double();
} else { } else {
result.successful = false; result.successful = false;
result.reason = "Unknown parameter: " + name; result.reason = "Unknown parameter: " + name;
@@ -631,6 +918,10 @@ private:
status = MV_CC_SetIntValue(camera_handle_, "AutoTargetBrightness", status = MV_CC_SetIntValue(camera_handle_, "AutoTargetBrightness",
static_cast<unsigned int>(exposure_auto_target_brightness_)); static_cast<unsigned int>(exposure_auto_target_brightness_));
} }
} else if (name == "ae_target_dn") {
ae_target_dn_ = static_cast<int>(param.as_int());
} else if (name == "ae_saturation_dn") {
ae_saturation_dn_ = static_cast<int>(param.as_int());
} else { } else {
result.successful = false; result.successful = false;
result.reason = "Unknown parameter: " + name; result.reason = "Unknown parameter: " + name;
@@ -702,6 +993,30 @@ private:
double exposure_auto_max_ = 10000.0; double exposure_auto_max_ = 10000.0;
int exposure_time_ = 5000; int exposure_time_ = 5000;
// Gain
bool gain_auto_ = false;
double gain_ = 0.0;
double gain_auto_max_db_ = 12.0;
// Software AE/AG — ROI-restricted percentile metering (runSoftwareAeStep() 참조)
bool use_software_ae_ = false;
double ae_roi_top_ratio_ = 0.0;
double ae_target_percentile_ = 70.0;
int ae_target_dn_ = 130;
double ae_saturation_percentile_ = 98.0;
int ae_saturation_dn_ = 245;
double ae_step_gain_ = 0.5;
double soft_ae_exposure_us_ = 1000.0;
double soft_ae_gain_db_ = 0.0;
// 노출/게인 마스터-슬레이브 동기화 (initSync() 참조)
std::string sync_role_ = "independent";
std::string sync_master_camera_ns_;
rclcpp::Publisher<std_msgs::msg::Float64>::SharedPtr ae_exposure_pub_;
rclcpp::Publisher<std_msgs::msg::Float64>::SharedPtr ae_gain_pub_;
rclcpp::Subscription<std_msgs::msg::Float64>::SharedPtr ae_exposure_sub_;
rclcpp::Subscription<std_msgs::msg::Float64>::SharedPtr ae_gain_sub_;
// White balance // White balance
bool balance_white_auto_ = false; bool balance_white_auto_ = false;