# 멀티카메라 + 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 — 광응답함수/비네팅 캘리브레이션 방법론)
