Compare commits
4 Commits
dev
..
0e98a96db2
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e98a96db2 | |||
| 5a587b0149 | |||
| 6eeb6a8297 | |||
| 539a97f3b4 |
@@ -6,3 +6,4 @@ log/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.vscode/
|
||||
.obsidian/
|
||||
|
||||
@@ -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절) |
|
||||
@@ -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 조합에서 슬레이브가 마스터 값을 잘 따라가는지
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
[develop]
|
||||
script_dir=$base/lib/hik_camera_panel
|
||||
[install]
|
||||
install_scripts=$base/lib/hik_camera_panel
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -3,96 +3,155 @@
|
||||
|
||||
# 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)
|
||||
- The image data captured by the Hikvision camera.
|
||||
- Hikvision 카메라가 캡처한 이미지 데이터.
|
||||
|
||||
- `<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`)
|
||||
- Enable continuous auto exposure. When `true`, the camera controls exposure automatically and `exposure_time` is ignored.
|
||||
- `<camera_name>/ae_gain_db` (std_msgs/msg/Float64)
|
||||
- 매 프레임 실제로 적용된 게인[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`)
|
||||
- Target brightness for auto exposure. Active only when `exposure_auto` is `true`. Can be changed at runtime:
|
||||
- `exposure_auto` (bool, 기본값: `false`)
|
||||
- Continuous 자동 노출을 켠다. `true`면 카메라가 노출을 자동으로 제어하고 `exposure_time`은 무시된다.
|
||||
|
||||
- `exposure_time` (double, 기본값: `5000`)
|
||||
- 수동 노출시간[µs]. `exposure_auto`가 `false`일 때만 사용된다.
|
||||
|
||||
- `exposure_auto_target_brightness` (int, 기본값: `128`, 범위: `0-255`)
|
||||
- 자동 노출의 목표 밝기. `exposure_auto`가 `true`일 때만 적용된다. 런타임에 변경 가능:
|
||||
```bash
|
||||
ros2 param set /hik_camera_ros2_driver exposure_auto_target_brightness 100
|
||||
```
|
||||
|
||||
- `exposure_auto_min` (double, default: `100.0`)
|
||||
- Auto exposure lower limit in microseconds. Active only when `exposure_auto` is `true`. Can be changed at runtime.
|
||||
- `exposure_auto_min` (double, 기본값: `100.0`)
|
||||
- 자동 노출 하한[µs]. `exposure_auto`가 `true`일 때만 적용된다. 런타임에 변경 가능.
|
||||
|
||||
- `exposure_auto_max` (double, default: `10000.0`)
|
||||
- Auto exposure upper limit in microseconds. Active only when `exposure_auto` is `true`. Can be changed at runtime.
|
||||
- `exposure_auto_max` (double, 기본값: `10000.0`)
|
||||
- 자동 노출 상한[µs]. `exposure_auto`가 `true`일 때만 적용된다. 런타임에 변경 가능.
|
||||
|
||||
- `gain` (double)
|
||||
- Manual gain. Auto gain is always disabled. Can be changed at runtime:
|
||||
- 수동 게인[dB]. `gain_auto`가 `false`일 때 사용된다. 런타임에 변경 가능:
|
||||
```bash
|
||||
ros2 param set /hik_camera_ros2_driver gain 2.0
|
||||
```
|
||||
|
||||
- `acquisition_frame_rate` (double, default: `165`)
|
||||
- The acquisition frame rate in hz for the camera.
|
||||
- `gain_auto` (bool, 기본값: `false`)
|
||||
- 자동 게인을 켠다. `use_software_ae`가 `false`면 카메라 온보드 `Continuous` 모드로, `true`면 아래
|
||||
소프트웨어 AE/AG 루프로 조절된다. 런타임에 변경 가능.
|
||||
|
||||
- `pixel_format` (string, default: `RGB8Packed`)
|
||||
- The pixel format for the image data. Supported values: `Mono8`, `Mono10`, `Mono12`, `RGB8Packed`, `BGR8Packed`, `YUV422_YUYV_Packed`, `YUV422Packed`, `BayerRG8`, `BayerRG10`, `BayerRG10Packed`, `BayerRG12`, `BayerRG12Packed`.
|
||||
- `gain_auto_max_db` (double, 기본값: `12.0`)
|
||||
- 자동 게인 상한[dB]. `gain` 파라미터 자체의 하드웨어 범위와는 별개 값으로, 하드웨어 실측 상한
|
||||
(이 카메라는 약 16.9 dB)보다 낮게 잡아 노이즈를 제한하는 용도.
|
||||
|
||||
- `adc_bit_depth` (string, default: `Bits_8`)
|
||||
- The ADC bit depth for the camera. Supported values: `Bits_8`, `Bits_12`.
|
||||
- `use_software_ae` (bool, 기본값: `false`)
|
||||
- 카메라 온보드 `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)
|
||||
- Whether to use the `sensor_data` QoS profile for image topic publication.
|
||||
- `ae_roi_top_ratio` (double, 기본값: `0.0`)
|
||||
- `use_software_ae`에서 측광 시 상단부터 제외할 프레임 높이 비율. `0.5`면 하단 절반만 측광한다
|
||||
(예: 하늘 제외).
|
||||
|
||||
- `camera_name` (string, default: `camera`)
|
||||
- The name of the camera for identification purposes.
|
||||
- `ae_target_percentile` / `ae_target_dn` (기본값: `70.0` / `130`)
|
||||
- 소프트웨어 AE/AG의 목표값: ROI 내 이 퍼센타일이 이 DN에 도달하도록 노출/게인을 조절한다.
|
||||
|
||||
- `frame_id` (string, default: `<camera_name>_optical_frame`)
|
||||
- The frame_id assigned to the published image data.
|
||||
- `ae_saturation_percentile` / `ae_saturation_dn` (기본값: `98.0` / `245`)
|
||||
- 하드 제약: 위 목표에 도달하지 못했더라도, 이 퍼센타일이 이 DN을 넘어설 정도로는 절대 밝게 하지
|
||||
않는다.
|
||||
|
||||
- `camera_topic` (string, default: `<camera_name>/image`)
|
||||
- The topic name for publishing image and info data.
|
||||
- `ae_step_gain` (double, 기본값: `0.5`)
|
||||
- 소프트웨어 AE/AG 루프의 프레임당 보정 댐핑(0~1).
|
||||
|
||||
- `camera_info_url` (string, default: `package://hik_camera_ros2_driver/config/camera_info.yaml`)
|
||||
- The URL for the camera calibration information file.
|
||||
- `sync_role` (string, 기본값: `"independent"`)
|
||||
- `"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`)
|
||||
- Enable hardware trigger mode (LINE0). When `true`, frame rate is controlled by the trigger signal.
|
||||
- `sync_master_camera_ns` (string, 기본값: `""`)
|
||||
- `sync_role`이 `"slave"`일 때, 구독할 마스터 카메라의 `camera_name` (예: `"cam2"`).
|
||||
|
||||
- `use_trigger_timestamp` (bool, default: `false`)
|
||||
- Use the shared memory timestamp written by the LiDAR driver instead of system time.
|
||||
> **동작 요약:** 마스터(예: `cam2`)의 `exposure_auto`/`gain_auto`(및 `use_software_ae`)가 전부
|
||||
> `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: `""`)
|
||||
- Select a specific camera by serial number. If empty, the first detected camera is used.
|
||||
- `acquisition_frame_rate` (double, 기본값: `165`)
|
||||
- 카메라의 취득 프레임률[Hz].
|
||||
|
||||
- `enable_interval_log` (bool, default: `false`)
|
||||
- Print per-frame timestamp interval logs (`[TS]`). Also prints a rolling summary every 10 frames (avg / min / max / jitter). Can be toggled at runtime:
|
||||
- `pixel_format` (string, 기본값: `RGB8Packed`)
|
||||
- 이미지 데이터의 픽셀 포맷. 지원값: `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
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
#### Run
|
||||
#### 실행
|
||||
|
||||
You can use the provided launch file for starting the camera node with default or custom parameters:
|
||||
제공된 launch 파일로 기본값 또는 커스텀 파라미터를 사용해 카메라 노드를 실행할 수 있다:
|
||||
|
||||
```bash
|
||||
ros2 launch hik_camera_ros2_driver hik_camera_launch.py
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/hik_camera_cam1:
|
||||
ros__parameters:
|
||||
camera_info_url: "package://hik_camera_ros2_driver/config/camera_info_cam1.yaml"
|
||||
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"
|
||||
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"
|
||||
use_sensor_data_qos: false
|
||||
camera_name: "cam1"
|
||||
# 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
|
||||
|
||||
# 프리런 모드(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_min: 100.0
|
||||
# 자동 노출인 경우
|
||||
exposure_auto: false
|
||||
exposure_auto_target_brightness: 100
|
||||
exposure_auto_min: 50.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 겹치는 영역 색감을 맞추기 위해 두 카메라에 동일한
|
||||
# 수동 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: 1411
|
||||
balance_ratio_red: 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:
|
||||
ros__parameters:
|
||||
camera_info_url: "package://hik_camera_ros2_driver/config/camera_info_cam2.yaml"
|
||||
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"
|
||||
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"
|
||||
use_sensor_data_qos: false
|
||||
camera_name: "cam2"
|
||||
# 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
|
||||
|
||||
# 프리런 모드(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_min: 100.0
|
||||
# 자동 노출인 경우
|
||||
exposure_auto: false
|
||||
exposure_auto_target_brightness: 100
|
||||
exposure_auto_min: 50.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 겹치는 영역 색감을 맞추기 위해 두 카메라에 동일한
|
||||
# 수동 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: 1411
|
||||
balance_ratio_red: 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:
|
||||
ros__parameters:
|
||||
camera_info_url: "package://hik_camera_ros2_driver/config/camera_info_cam3.yaml"
|
||||
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"
|
||||
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"
|
||||
use_sensor_data_qos: false
|
||||
camera_name: "cam3"
|
||||
# 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
|
||||
|
||||
# 프리런 모드(trigger_enable: false)일 때만 적용
|
||||
acquisition_frame_rate: 10.0 # Unit: Hz
|
||||
acquisition_frame_rate: 10.0 # Unit: Hz
|
||||
|
||||
# cam1/cam2와 동일하게 맞춤 (밝기 차이 최소화)
|
||||
exposure_auto: true
|
||||
exposure_auto_target_brightness: 145
|
||||
exposure_auto_min: 100.0
|
||||
exposure_auto_max: 8000.0
|
||||
exposure_time: 5000 # Unit: us
|
||||
gain: 12.0 # Range: 0.0 ~ 16.9, Unit: dB
|
||||
# 자동 노출인 경우
|
||||
exposure_auto: false
|
||||
exposure_auto_target_brightness: 100
|
||||
exposure_auto_min: 50.0
|
||||
exposure_auto_max: 5000.0
|
||||
|
||||
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_blue: 2122
|
||||
balance_ratio_blue: 1024
|
||||
|
||||
# balance_ratio_red: 1411
|
||||
# balance_ratio_green: 1024
|
||||
# balance_ratio_blue: 2122
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<depend>rclcpp</depend>
|
||||
<depend>rclcpp_components</depend>
|
||||
<depend>sensor_msgs</depend>
|
||||
<depend>std_msgs</depend>
|
||||
<depend>image_transport</depend>
|
||||
<depend>image_transport_plugins</depend>
|
||||
<depend>camera_info_manager</depend>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
@@ -11,6 +13,7 @@
|
||||
#include "image_transport/image_transport.hpp"
|
||||
#include "rclcpp/logging.hpp"
|
||||
#include "rclcpp/utilities.hpp"
|
||||
#include "std_msgs/msg/float64.hpp"
|
||||
|
||||
namespace hik_camera_ros2_driver
|
||||
{
|
||||
@@ -32,6 +35,7 @@ public:
|
||||
declareParameters();
|
||||
startCamera();
|
||||
initSharedMemory();
|
||||
initSync();
|
||||
|
||||
params_callback_handle_ = this->add_on_set_parameters_callback(
|
||||
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);
|
||||
}
|
||||
|
||||
// 노출/게인 동기화 (마스터-슬레이브) — 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 — 모든 파라미터를 항상 선언하여 런타임 모드 전환 지원
|
||||
MV_CC_GetFloatValue(camera_handle_, "ExposureTime", &f_value);
|
||||
exposure_auto_ = this->declare_parameter("exposure_auto", false);
|
||||
@@ -193,17 +207,65 @@ private:
|
||||
param_desc.description = "Manual exposure time in microseconds";
|
||||
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();
|
||||
|
||||
// Gain (manual only — GainAuto=Off)
|
||||
MV_CC_SetEnumValue(camera_handle_, "GainAuto", 0);
|
||||
// Gain
|
||||
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";
|
||||
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].to_value = static_cast<int64_t>(f_value.fMax);
|
||||
double gain = this->declare_parameter("gain", f_value.fCurValue, param_desc);
|
||||
MV_CC_SetFloatValue(camera_handle_, "Gain", gain);
|
||||
RCLCPP_INFO(this->get_logger(), "Gain: %f", gain);
|
||||
gain_ = this->declare_parameter("gain", static_cast<double>(f_value.fCurValue), param_desc);
|
||||
|
||||
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 비율.
|
||||
// 이유: 카메라별로 독립 Continuous AWB를 켜두면 두 카메라가 서로 다른 화각을 보고
|
||||
@@ -320,8 +382,65 @@ private:
|
||||
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()
|
||||
{
|
||||
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_) {
|
||||
MV_CC_SetEnumValue(camera_handle_, "ExposureAuto", 2); // Continuous
|
||||
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으로 고정되어
|
||||
// 실사용 불가로 확인됨 (실기기 로그로 검증). 대신 nDevTimeStampHigh/Low(카메라 자체
|
||||
// 자유구동 하드웨어 클럭)는 프레임마다 정상적으로 증가하는 것을 로그로 확인했다.
|
||||
@@ -479,6 +637,100 @@ private:
|
||||
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()
|
||||
{
|
||||
MV_FRAME_OUT out_frame;
|
||||
@@ -539,6 +791,13 @@ private:
|
||||
camera_info_msg_.header = image_msg_.header;
|
||||
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);
|
||||
|
||||
static auto last_log_time = std::chrono::steady_clock::now();
|
||||
@@ -593,6 +852,17 @@ private:
|
||||
} else if (name == "exposure_auto") {
|
||||
exposure_auto_ = param.as_bool();
|
||||
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 {
|
||||
result.successful = false;
|
||||
result.reason = "Unknown parameter: " + name;
|
||||
@@ -600,7 +870,10 @@ private:
|
||||
}
|
||||
} else if (type == rclcpp::ParameterType::PARAMETER_DOUBLE) {
|
||||
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") {
|
||||
exposure_auto_min_ = param.as_double();
|
||||
if (exposure_auto_) {
|
||||
@@ -613,6 +886,20 @@ private:
|
||||
status = MV_CC_SetIntValue(camera_handle_, "AutoExposureTimeUpperLimit",
|
||||
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 {
|
||||
result.successful = false;
|
||||
result.reason = "Unknown parameter: " + name;
|
||||
@@ -631,6 +918,10 @@ private:
|
||||
status = MV_CC_SetIntValue(camera_handle_, "AutoTargetBrightness",
|
||||
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 {
|
||||
result.successful = false;
|
||||
result.reason = "Unknown parameter: " + name;
|
||||
@@ -702,6 +993,30 @@ private:
|
||||
double exposure_auto_max_ = 10000.0;
|
||||
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
|
||||
bool balance_white_auto_ = false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user