2 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 00:03:42 +09:00
13 changed files with 780 additions and 86 deletions
+47
View File
@@ -0,0 +1,47 @@
# hik_camera_panel
`hik_camera_ros2_driver`(cam1/cam2/cam3)의 노출·게인·ROI 파라미터를 슬라이더 또는 직접 숫자
입력으로 실시간 조절하는 패널. 카메라별 탭 안에 실시간 이미지 미리보기가 있고, ROI(측광 제외
상단 비율)를 조절하면 그 경계선이 이미지 위에 바로 그려진다.
## 실행
카메라 노드(`hik_camera_cam1`/`hik_camera_cam2`/`hik_camera_cam3`)가 먼저 떠 있어야 한다.
```bash
ros2 run hik_camera_panel panel_node
```
## 구성
- 탭 3개(cam1/cam2/cam3), 각 탭은 `/hik_camera_camN/get_parameters`·`/hik_camera_camN/set_parameters`
서비스를 직접 호출해서 값을 읽고 쓴다.
- 그룹: 노출 / 게인 / 소프트웨어 AE·ROI / 동기화.
- 숫자 파라미터는 슬라이더+스핀박스를 같이 제공한다. 슬라이더를 드래그하는 동안은 로컬
미리보기(ROI 오버레이)만 갱신되고, 손을 뗀 시점에 실제로 카메라에 값을 적용한다. 스핀박스는
값을 직접 입력하고 포커스를 벗어나면(또는 Enter) 바로 적용된다.
- 노출/노출상한/노출하한은 범위가 15µs~100ms로 넓어서 슬라이더를 로그 스케일로 매핑했다
(`param_spec.py``log_scale=True`).
- ROI 미리보기: 카메라의 `/<camera_name>/image` 토픽을 구독해서 QImage로 바로 그린다
(cv_bridge/OpenCV 의존성 없음 — 드라이버가 항상 `rgb8`로 발행하므로 `QImage.Format_RGB888`
직접 변환 가능).
## 알아둘 것
1. **`sync_role`/`sync_master_camera_ns`는 조회만 되고 편집은 막혀 있다.** 드라이버가 publisher/
subscriber를 노드 시작 시 `initSync()`에서 한 번만 만들기 때문에, 런타임에 이 값을 바꿔도
반영되지 않는다 (`hik_camera_node.cpp``dynamicParametersCallback()`도 이 두 파라미터는
처리하지 않음 — 시도하면 "Unknown parameter"로 거부됨). 마스터/슬레이브 역할을 바꾸려면
yaml을 고치고 노드를 재시작해야 한다.
2. **나머지 파라미터는 이번에 `hik_camera_node.cpp``dynamicParametersCallback()`을 확장해서
전부 런타임에 반영되도록 만들었다** (`gain_auto_max_db`, `ae_roi_top_ratio`,
`ae_target_percentile`, `ae_target_dn`, `ae_saturation_percentile`, `ae_saturation_dn`,
`ae_step_gain`). 이 패널이 슬라이더로 값을 바꿨을 때 실제로 카메라에 반영되려면 드라이버
쪽도 이 커밋 이후 버전이어야 한다.
3. **개발 PC(macOS)에는 ROS 2와 `python_qt_binding`이 없어서 실행 검증을 못 했다.** Python
문법 검사(`python3 -m py_compile`)와 슬라이더↔값 변환 로직의 라운드트립 테스트만 순수
Python으로 돌려봤고, 실제 rclpy 파라미터 서비스 호출·Qt 위젯 동작·이미지 렌더링은 로봇
PC에서 `ros2 run hik_camera_panel panel_node`로 직접 확인해야 한다.
4. 카메라 노드/토픽 이름이 `camera_params_cam{1,2,3}.yaml`
`hik_camera_triple_launch.py`와 다르면 `panel_node.py``DEFAULT_CAMERAS` 목록을 맞춰
수정할 것.
@@ -0,0 +1,440 @@
"""hik_camera_ros2_driver 노출/게인/ROI 파라미터 조절 패널.
카메라 노드(hik_camera_cam1/cam2/cam3)의 ROS 2 파라미터 서비스(get_parameters/
set_parameters)를 직접 호출해서 슬라이더/스핀박스로 값을 읽고 쓴다. ROI(측광 제외
상단 비율)는 카메라의 실시간 이미지 위에 경계선을 오버레이해서 눈으로 보면서
조절할 수 있게 했다.
실행:
ros2 run hik_camera_panel panel_node
전제:
- hik_camera_cam1/cam2/cam3 노드가 이미 떠 있어야 한다 (안 떠 있으면 "새로고침"/
슬라이더 조작 시 상태바에 실패 메시지가 뜬다).
- sync_role / sync_master_camera_ns는 조회만 가능하고 편집은 막아뒀다 — 드라이버가
publisher/subscriber를 노드 시작 시 한 번만 만들기 때문에 런타임에 값을 바꿔도
반영되지 않는다 (초기화 흐름을 다시 태우려면 노드 재시작 필요).
"""
import sys
import threading
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rcl_interfaces.msg import Parameter, ParameterType, ParameterValue
from rcl_interfaces.srv import GetParameters, SetParameters
from sensor_msgs.msg import Image
from python_qt_binding.QtCore import QObject, Qt, pyqtSignal
from python_qt_binding.QtGui import QColor, QImage, QPainter, QPen, QPixmap
from python_qt_binding.QtWidgets import (
QApplication,
QCheckBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QSlider,
QSpinBox,
QStatusBar,
QTabWidget,
QVBoxLayout,
QWidget,
)
from hik_camera_panel.param_spec import PARAM_SPEC_BY_NAME, PARAM_SPECS, SLIDER_STEPS, \
slider_to_value, value_to_slider
# (node_name, camera_name) — node_name은 파라미터 서비스(/<node_name>/set_parameters)용,
# camera_name은 이미지 토픽(/<camera_name>/image)용. hik_camera_triple_launch.py /
# camera_params_cam{1,2,3}.yaml과 일치해야 한다.
DEFAULT_CAMERAS = [
('hik_camera_cam1', 'cam1'),
('hik_camera_cam2', 'cam2'),
('hik_camera_cam3', 'cam3'),
]
def make_parameter_msg(name, type_str, value):
pv = ParameterValue()
if type_str == 'bool':
pv.type = ParameterType.PARAMETER_BOOL
pv.bool_value = bool(value)
elif type_str == 'int':
pv.type = ParameterType.PARAMETER_INTEGER
pv.integer_value = int(round(value))
elif type_str == 'double':
pv.type = ParameterType.PARAMETER_DOUBLE
pv.double_value = float(value)
else:
pv.type = ParameterType.PARAMETER_STRING
pv.string_value = str(value)
msg = Parameter()
msg.name = name
msg.value = pv
return msg
def parameter_value_to_python(pv):
if pv.type == ParameterType.PARAMETER_BOOL:
return pv.bool_value
if pv.type == ParameterType.PARAMETER_INTEGER:
return pv.integer_value
if pv.type == ParameterType.PARAMETER_DOUBLE:
return pv.double_value
if pv.type == ParameterType.PARAMETER_STRING:
return pv.string_value
return None
class RosBridge(QObject):
"""rclpy 노드를 백그라운드 스레드에서 spin하고, 결과는 Qt 시그널로 GUI 스레드에 넘긴다.
파라미터 서비스 호출은 항상 call_async + add_done_callback으로 비동기 처리한다.
add_done_callback은 spin 스레드에서 실행되므로, 그 안에서 위젯을 직접 건드리지 않고
시그널만 emit한다 (Qt가 큐잉해서 GUI 스레드에서 슬롯을 실행해준다).
"""
params_fetched = pyqtSignal(str, dict) # node_name, {param_name: value}
param_set_result = pyqtSignal(str, str, bool, str) # node_name, param_name, ok, reason
image_received = pyqtSignal(str, QImage) # camera_name, image
def __init__(self):
super().__init__()
self._node = Node('hik_camera_panel')
self._set_clients = {}
self._get_clients = {}
self._image_subs = {}
self._spin_thread = threading.Thread(target=self._spin, daemon=True)
self._spin_thread.start()
def _spin(self):
rclpy.spin(self._node)
# -- 서비스 클라이언트 --------------------------------------------------
def _set_client(self, node_name):
if node_name not in self._set_clients:
self._set_clients[node_name] = self._node.create_client(
SetParameters, f'/{node_name}/set_parameters')
return self._set_clients[node_name]
def _get_client(self, node_name):
if node_name not in self._get_clients:
self._get_clients[node_name] = self._node.create_client(
GetParameters, f'/{node_name}/get_parameters')
return self._get_clients[node_name]
def fetch_parameters(self, node_name, specs):
client = self._get_client(node_name)
if not client.service_is_ready():
self.param_set_result.emit(
node_name, '(새로고침)', False,
'파라미터 서비스에 연결할 수 없음 — 노드가 실행 중인지 확인하세요')
return
req = GetParameters.Request()
req.names = [s['name'] for s in specs]
future = client.call_async(req)
def _done(fut):
try:
resp = fut.result()
except Exception as exc: # noqa: BLE001 - 서비스 호출 자체 실패를 그대로 보고
self.param_set_result.emit(node_name, '(새로고침)', False, str(exc))
return
values = {}
for spec, pv in zip(specs, resp.values):
values[spec['name']] = parameter_value_to_python(pv)
self.params_fetched.emit(node_name, values)
future.add_done_callback(_done)
def set_parameter(self, node_name, name, type_str, value):
client = self._set_client(node_name)
if not client.service_is_ready():
self.param_set_result.emit(
node_name, name, False,
'파라미터 서비스에 연결할 수 없음 — 노드가 실행 중인지 확인하세요')
return
req = SetParameters.Request()
req.parameters = [make_parameter_msg(name, type_str, value)]
future = client.call_async(req)
def _done(fut):
try:
resp = fut.result()
except Exception as exc: # noqa: BLE001
self.param_set_result.emit(node_name, name, False, str(exc))
return
result = resp.results[0]
self.param_set_result.emit(node_name, name, result.successful, result.reason)
future.add_done_callback(_done)
# -- 이미지 구독 ----------------------------------------------------------
def subscribe_image(self, camera_name):
if camera_name in self._image_subs:
return
def _cb(msg):
if msg.encoding != 'rgb8':
return
qimg = QImage(
bytes(msg.data), msg.width, msg.height, msg.step, QImage.Format_RGB888).copy()
self.image_received.emit(camera_name, qimg)
self._image_subs[camera_name] = self._node.create_subscription(
Image, f'/{camera_name}/image', _cb, qos_profile_sensor_data)
def shutdown(self):
rclpy.shutdown()
class ParamRow(QWidget):
"""파라미터 하나를 표시하는 한 줄: bool=체크박스, string=텍스트박스(읽기전용 가능),
나머지(int/double)=슬라이더+스핀박스 동시 제공.
- previewChanged: 슬라이더 드래그 중(아직 손 안 뗌) 매번 emit — 로컬 미리보기(ROI 오버레이
등)만 갱신하고 아직 카메라에는 보내지 않음.
- valueEdited: 슬라이더에서 손을 떼거나, 스핀박스 편집을 마치거나, 체크박스를 토글했을 때
emit — 이때 실제로 ros2 파라미터를 설정한다.
"""
valueEdited = pyqtSignal(str, object)
previewChanged = pyqtSignal(str, object)
def __init__(self, spec, parent=None):
super().__init__(parent)
self.spec = spec
self._suppress = False
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.checkbox = None
self.line_edit = None
self.slider = None
self.spin = None
if spec['type'] == 'bool':
self.checkbox = QCheckBox()
self.checkbox.toggled.connect(self._on_bool_changed)
layout.addWidget(self.checkbox)
elif spec['type'] == 'string':
self.line_edit = QLineEdit()
self.line_edit.setReadOnly(spec.get('readonly', False))
if spec.get('readonly'):
self.line_edit.setToolTip('런타임 변경 미지원 — 노드 재시작 필요')
layout.addWidget(self.line_edit)
else:
self.slider = QSlider(Qt.Horizontal)
self.slider.setRange(0, SLIDER_STEPS)
if spec['type'] == 'int':
self.spin = QSpinBox()
self.spin.setRange(int(spec['min']), int(spec['max']))
else:
self.spin = QDoubleSpinBox()
self.spin.setRange(spec['min'], spec['max'])
decimals = spec.get('decimals', 2)
self.spin.setDecimals(decimals)
self.spin.setSingleStep(10 ** (-decimals) if decimals > 0 else 1.0)
layout.addWidget(self.slider, 3)
layout.addWidget(self.spin, 1)
self.slider.sliderMoved.connect(self._on_slider_moved)
self.slider.sliderReleased.connect(self._on_slider_released)
self.spin.editingFinished.connect(self._on_spin_edited)
def set_value(self, value):
self._suppress = True
try:
if self.checkbox is not None:
self.checkbox.setChecked(bool(value))
elif self.line_edit is not None:
self.line_edit.setText(str(value))
else:
self.spin.setValue(value)
self.slider.setValue(value_to_slider(
value, self.spec['min'], self.spec['max'], self.spec.get('log_scale', False)))
finally:
self._suppress = False
def _on_bool_changed(self, checked):
if not self._suppress:
self.valueEdited.emit(self.spec['name'], checked)
def _on_slider_moved(self, pos):
value = slider_to_value(
pos, self.spec['min'], self.spec['max'], self.spec.get('log_scale', False))
self._suppress = True
self.spin.setValue(value)
self._suppress = False
self.previewChanged.emit(self.spec['name'], value)
def _on_slider_released(self):
value = slider_to_value(
self.slider.value(), self.spec['min'], self.spec['max'],
self.spec.get('log_scale', False))
self.valueEdited.emit(self.spec['name'], value)
def _on_spin_edited(self):
if self._suppress:
return
value = self.spin.value()
self._suppress = True
self.slider.setValue(value_to_slider(
value, self.spec['min'], self.spec['max'], self.spec.get('log_scale', False)))
self._suppress = False
self.valueEdited.emit(self.spec['name'], value)
class CameraTab(QWidget):
def __init__(self, node_name, camera_name, bridge, parent=None):
super().__init__(parent)
self.node_name = node_name
self.camera_name = camera_name
self.bridge = bridge
self.rows = {}
self._latest_image = None
self._roi_ratio = 0.0
outer = QHBoxLayout(self)
left_layout = QVBoxLayout()
groups = {}
for spec in PARAM_SPECS:
group_name = spec['group']
if group_name not in groups:
box = QGroupBox(group_name)
box.setLayout(QFormLayout())
groups[group_name] = box
left_layout.addWidget(box)
row = ParamRow(spec)
row.valueEdited.connect(self._on_value_edited)
row.previewChanged.connect(self._on_preview_changed)
groups[group_name].layout().addRow(spec['label'], row)
self.rows[spec['name']] = row
refresh_btn = QPushButton('새로고침')
refresh_btn.clicked.connect(self.refresh)
left_layout.addWidget(refresh_btn)
left_layout.addStretch(1)
left_widget = QWidget()
left_widget.setLayout(left_layout)
self.preview = QLabel('이미지 대기 중...')
self.preview.setMinimumSize(480, 360)
self.preview.setAlignment(Qt.AlignCenter)
self.preview.setStyleSheet('background-color: #202020; color: #aaaaaa;')
outer.addWidget(left_widget, 2)
outer.addWidget(self.preview, 3)
self.bridge.params_fetched.connect(self._on_params_fetched)
self.bridge.image_received.connect(self._on_image_received)
self.bridge.subscribe_image(camera_name)
self.refresh()
def refresh(self):
self.bridge.fetch_parameters(self.node_name, PARAM_SPECS)
def _on_params_fetched(self, node_name, values):
if node_name != self.node_name:
return
for name, value in values.items():
if name in self.rows:
self.rows[name].set_value(value)
if 'ae_roi_top_ratio' in values:
self._roi_ratio = values['ae_roi_top_ratio']
self._redraw_preview()
def _on_value_edited(self, name, value):
spec = PARAM_SPEC_BY_NAME[name]
if spec.get('readonly'):
return
self.bridge.set_parameter(self.node_name, name, spec['type'], value)
if name == 'ae_roi_top_ratio':
self._roi_ratio = value
self._redraw_preview()
def _on_preview_changed(self, name, value):
if name == 'ae_roi_top_ratio':
self._roi_ratio = value
self._redraw_preview()
def _on_image_received(self, camera_name, qimg):
if camera_name != self.camera_name:
return
self._latest_image = qimg
self._redraw_preview()
def _redraw_preview(self):
if self._latest_image is None:
return
target_width = self.preview.width() if self.preview.width() > 0 else 480
pixmap = QPixmap.fromImage(self._latest_image).scaledToWidth(
target_width, Qt.SmoothTransformation)
painter = QPainter(pixmap)
w, h = pixmap.width(), pixmap.height()
boundary_y = int(h * self._roi_ratio)
if boundary_y > 0:
painter.fillRect(0, 0, w, boundary_y, QColor(0, 0, 0, 120))
pen = QPen(QColor(255, 60, 60))
pen.setWidth(2)
painter.setPen(pen)
painter.drawLine(0, boundary_y, w, boundary_y)
painter.end()
self.preview.setPixmap(pixmap)
class MainWindow(QMainWindow):
def __init__(self, bridge, cameras):
super().__init__()
self.setWindowTitle('hik_camera 노출 / 게인 / ROI 패널')
self.bridge = bridge
tabs = QTabWidget()
for node_name, camera_name in cameras:
tabs.addTab(CameraTab(node_name, camera_name, bridge), camera_name)
self.setCentralWidget(tabs)
self.setStatusBar(QStatusBar())
bridge.param_set_result.connect(self._on_param_set_result)
self.resize(1280, 720)
def _on_param_set_result(self, node_name, name, ok, reason):
if ok:
self.statusBar().showMessage(f'[{node_name}] {name} 적용됨', 2000)
else:
self.statusBar().showMessage(f'[{node_name}] {name} 실패: {reason}', 6000)
def main(args=None):
rclpy.init(args=args)
app = QApplication(sys.argv)
bridge = RosBridge()
window = MainWindow(bridge, DEFAULT_CAMERAS)
window.show()
exit_code = app.exec_()
bridge.shutdown()
sys.exit(exit_code)
if __name__ == '__main__':
main()
@@ -0,0 +1,81 @@
"""hik_camera_ros2_driver가 선언하는 파라미터 스펙.
이름/타입/범위는 hik_camera_node.cpp의 declareParameters()와 dynamicParametersCallback()에
선언/처리되는 것과 반드시 일치해야 한다 (그쪽을 바꾸면 여기도 같이 바꿀 것).
sync_role / sync_master_camera_ns는 런타임에 값을 바꿔도 드라이버가 무시한다
(subscription/publisher가 노드 시작 시 initSync()에서 한 번만 만들어짐) — 그래서
readonly=True로 표시해 패널에서는 조회만 하고 편집은 막는다.
"""
import math
SLIDER_STEPS = 1000
PARAM_SPECS = [
# --- 노출 ---
dict(name='exposure_auto', type='bool', group='노출', label='자동 노출'),
dict(name='exposure_time', type='int', group='노출', label='수동 노출시간 [us]',
min=15, max=100000, log_scale=True),
dict(name='exposure_auto_target_brightness', type='int', group='노출',
label='목표 밝기 (온보드 AE)', min=0, max=255),
dict(name='exposure_auto_min', type='double', group='노출', label='자동 노출 하한 [us]',
min=15.0, max=100000.0, log_scale=True, decimals=0),
dict(name='exposure_auto_max', type='double', group='노출', label='자동 노출 상한 [us]',
min=15.0, max=100000.0, log_scale=True, decimals=0),
# --- 게인 ---
dict(name='gain', type='double', group='게인', label='수동 게인 [dB]',
min=0.0, max=17.0, decimals=1),
dict(name='gain_auto', type='bool', group='게인', label='자동 게인'),
dict(name='gain_auto_max_db', type='double', group='게인', label='자동 게인 상한 [dB]',
min=0.0, max=17.0, decimals=1),
# --- 소프트웨어 AE / ROI ---
dict(name='use_software_ae', type='bool', group='소프트웨어 AE / ROI',
label='소프트웨어 AE 사용'),
dict(name='ae_roi_top_ratio', type='double', group='소프트웨어 AE / ROI',
label='측광 제외 상단 비율 (0.5=하단 절반만)', min=0.0, max=0.95, decimals=2),
dict(name='ae_target_percentile', type='double', group='소프트웨어 AE / ROI',
label='목표 퍼센타일', min=0.0, max=100.0, decimals=1),
dict(name='ae_target_dn', type='int', group='소프트웨어 AE / ROI',
label='목표 DN', min=0, max=255),
dict(name='ae_saturation_percentile', type='double', group='소프트웨어 AE / ROI',
label='포화 방지 퍼센타일', min=0.0, max=100.0, decimals=1),
dict(name='ae_saturation_dn', type='int', group='소프트웨어 AE / ROI',
label='포화 방지 DN', min=0, max=255),
dict(name='ae_step_gain', type='double', group='소프트웨어 AE / ROI',
label='보정 댐핑 (0~1)', min=0.0, max=1.0, decimals=2),
# --- 동기화 (재시작 필요 — 아래 readonly 참고) ---
dict(name='sync_role', type='string', group='동기화 (재시작 필요)', label='역할',
readonly=True),
dict(name='sync_master_camera_ns', type='string', group='동기화 (재시작 필요)',
label='마스터 camera_name', readonly=True),
]
PARAM_SPEC_BY_NAME = {spec['name']: spec for spec in PARAM_SPECS}
def value_to_slider(value, vmin, vmax, log_scale):
"""실제 값을 0~SLIDER_STEPS 정수 슬라이더 위치로 변환."""
value = min(max(value, vmin), vmax)
if log_scale:
vmin_eff = max(vmin, 1e-9)
value_eff = max(value, vmin_eff)
lo, hi = math.log(vmin_eff), math.log(vmax)
frac = (math.log(value_eff) - lo) / (hi - lo) if hi > lo else 0.0
else:
frac = (value - vmin) / (vmax - vmin) if vmax > vmin else 0.0
return int(round(frac * SLIDER_STEPS))
def slider_to_value(pos, vmin, vmax, log_scale):
"""슬라이더 위치(0~SLIDER_STEPS)를 실제 값으로 변환."""
frac = min(max(pos, 0), SLIDER_STEPS) / SLIDER_STEPS
if log_scale:
vmin_eff = max(vmin, 1e-9)
lo, hi = math.log(vmin_eff), math.log(vmax)
return math.exp(lo + frac * (hi - lo))
return vmin + frac * (vmax - vmin)
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>hik_camera_panel</name>
<version>1.0.0</version>
<description>
hik_camera_ros2_driver의 노출/게인/ROI 파라미터를 슬라이더 또는 직접 입력으로
실시간 조절하는 PyQt(python_qt_binding) 기반 패널. 카메라 3대(cam1/cam2/cam3) 탭과
ROI 경계가 오버레이된 실시간 이미지 미리보기를 제공한다.
</description>
<maintainer email="khj@example.com">khj</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_python</buildtool_depend>
<depend>rclpy</depend>
<depend>rcl_interfaces</depend>
<depend>sensor_msgs</depend>
<exec_depend>python_qt_binding</exec_depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
+4
View File
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/hik_camera_panel
[install]
install_scripts=$base/lib/hik_camera_panel
+28
View File
@@ -0,0 +1,28 @@
from setuptools import find_packages, setup
package_name = 'hik_camera_panel'
setup(
name=package_name,
version='1.0.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='khj',
maintainer_email='khj@example.com',
description=(
'hik_camera_ros2_driver의 노출/게인/ROI 파라미터를 슬라이더/직접입력으로 '
'실시간 조절하는 패널'
),
license='Apache-2.0',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'panel_node = hik_camera_panel.panel_node:main',
],
},
)
+100 -86
View File
@@ -3,141 +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, used when `gain_auto` is `false`. Can be changed at runtime:
- 수동 게인[dB]. `gain_auto` `false`일 때 사용된다. 런타임에 변경 가능:
```bash
ros2 param set /hik_camera_ros2_driver gain 2.0
```
- `gain_auto` (bool, default: `false`)
- Enable auto gain. Onboard `Continuous` when `use_software_ae` is `false`; controlled by the
software AE/AG loop (see below) when `use_software_ae` is `true`. Can be changed at runtime.
- `gain_auto` (bool, 기본값: `false`)
- 자동 게인을 켠다. `use_software_ae` `false`면 카메라 온보드 `Continuous` 모드로, `true`면 아래
소프트웨어 AE/AG 루프로 조절된다. 런타임에 변경 가능.
- `gain_auto_max_db` (double, default: `12.0`)
- Ceiling for auto gain, in dB. Independent from `gain`'s own hardware range — lets you cap
auto gain below the hardware maximum (e.g. this camera's real limit is ~16.9 dB) to bound
noise.
- `gain_auto_max_db` (double, 기본값: `12.0`)
- 자동 게인 상한[dB]. `gain` 파라미터 자체의 하드웨어 범위와는 별개 값으로, 하드웨어 실측 상한
(이 카메라는 약 16.9 dB)보다 낮게 잡아 노이즈를 제한하는 용도.
- `use_software_ae` (bool, default: `false`)
- The camera's onboard `ExposureAuto`/`GainAuto` always meter the *full* captured frame — this
SDK has no GenICam node for a metering-only ROI distinct from the capture AOI. When `true`,
onboard auto is disabled and exposure/gain are instead computed each frame in software from a
percentile of the ROI defined by `ae_roi_top_ratio`, then written directly via
`ExposureTime`/`Gain`. Requires `exposure_auto` and/or `gain_auto` to also be `true` to
actually adjust anything (otherwise held fixed at `exposure_time`/`gain`). Can be changed at
runtime.
- `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` 값에 고정됨). 런타임에 변경 가능.
- `ae_roi_top_ratio` (double, default: `0.0`)
- Fraction of frame height excluded from the top when metering under `use_software_ae`. `0.5`
meters only the bottom half (e.g. to exclude sky).
- `ae_roi_top_ratio` (double, 기본값: `0.0`)
- `use_software_ae`에서 측광 시 상단부터 제외할 프레임 높이 비율. `0.5`면 하단 절반만 측광한다
(예: 하늘 제외).
- `ae_target_percentile` / `ae_target_dn` (default: `70.0` / `130`)
- Software AE/AG target: adjust exposure/gain so this percentile of the ROI reaches this DN.
- `ae_target_percentile` / `ae_target_dn` (기본값: `70.0` / `130`)
- 소프트웨어 AE/AG의 목표값: ROI 내 이 퍼센타일이 이 DN에 도달하도록 노출/게인을 조절한다.
- `ae_saturation_percentile` / `ae_saturation_dn` (default: `98.0` / `245`)
- Hard ceiling: never brighten past the point where this percentile would exceed this DN, even
if the target above hasn't been reached.
- `ae_saturation_percentile` / `ae_saturation_dn` (기본값: `98.0` / `245`)
- 하드 제약: 위 목표에 도달하지 못했더라도, 이 퍼센타일이 이 DN을 넘어설 정도로는 절대 밝게 하지
않는다.
- `ae_step_gain` (double, default: `0.5`)
- Per-frame correction damping (0-1) for the software AE/AG loop.
- `ae_step_gain` (double, 기본값: `0.5`)
- 소프트웨어 AE/AG 루프의 프레임당 보정 댐핑(0~1).
- `sync_role` (string, default: `"independent"`)
- `"independent"`: this camera decides its own exposure/gain (default, unchanged behavior).
- `"master"`: publishes the actually-applied exposure/gain (from the SDK's per-frame frame info,
valid regardless of AE mode) on `<camera_name>/ae_exposure_time_us` and
`<camera_name>/ae_gain_db`.
- `"slave"`: ignores its own auto exposure/gain and instead applies whatever `sync_master_camera_ns`
publishes, directly. Use this to make one camera (e.g. the center one) drive exposure/gain for
the others.
- `sync_role` (string, 기본값: `"independent"`)
- `"independent"`: 이 카메라가 자기 노출/게인을 스스로 결정한다 (기본값, 기존 동작과 동일).
- `"master"`: SDK가 매 프레임 주는 실제 적용값(AE 모드와 무관하게 항상 유효)을
`<camera_name>/ae_exposure_time_us`, `<camera_name>/ae_gain_db`로 발행한다.
- `"slave"`: 자기 자신의 자동 노출/게인 로직을 완전히 무시하고, `sync_master_camera_ns`
발행하는 값을 그대로 적용한다. 카메라 여러 대 중 하나(예: 가운데 카메라)가 나머지의
노출/게인을 결정하게 하고 싶을 때 사용한다.
- `sync_master_camera_ns` (string, default: `""`)
- When `sync_role` is `"slave"`, the `camera_name` of the master camera to subscribe to (e.g.
`"cam2"`).
- `sync_master_camera_ns` (string, 기본값: `""`)
- `sync_role` `"slave"`일 때, 구독할 마스터 카메라의 `camera_name` (예: `"cam2"`).
- `acquisition_frame_rate` (double, default: `165`)
- The acquisition frame rate in hz for the camera.
> **동작 요약:** 마스터(예: `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` 값은 (설정되어 있더라도) 완전히
> 무시된다 — 노출/게인 자동 조절 여부는 오직 마스터 쪽 설정만으로 결정된다.
- `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`.
- `acquisition_frame_rate` (double, 기본값: `165`)
- 카메라의 취득 프레임률[Hz].
- `adc_bit_depth` (string, default: `Bits_8`)
- The ADC bit depth for the camera. Supported values: `Bits_8`, `Bits_12`.
- `pixel_format` (string, 기본값: `RGB8Packed`)
- 이미지 데이터의 픽셀 포맷. 지원값: `Mono8`, `Mono10`, `Mono12`, `RGB8Packed`, `BGR8Packed`,
`YUV422_YUYV_Packed`, `YUV422Packed`, `BayerRG8`, `BayerRG10`, `BayerRG10Packed`, `BayerRG12`,
`BayerRG12Packed`.
- `use_sensor_data_qos` (bool, default: true)
- Whether to use the `sensor_data` QoS profile for image topic publication.
- `adc_bit_depth` (string, 기본값: `Bits_8`)
- 카메라의 ADC 비트 심도. 지원값: `Bits_8`, `Bits_12`.
- `camera_name` (string, default: `camera`)
- The name of the camera for identification purposes.
- `use_sensor_data_qos` (bool, 기본값: true)
- 이미지 토픽 발행에 `sensor_data` QoS 프로파일을 쓸지 여부.
- `frame_id` (string, default: `<camera_name>_optical_frame`)
- The frame_id assigned to the published image data.
- `camera_name` (string, 기본값: `camera`)
- 카메라 식별용 이름.
- `camera_topic` (string, default: `<camera_name>/image`)
- The topic name for publishing image and info data.
- `frame_id` (string, 기본값: `<camera_name>_optical_frame`)
- 발행되는 이미지 데이터에 붙는 frame_id.
- `camera_info_url` (string, default: `package://hik_camera_ros2_driver/config/camera_info.yaml`)
- The URL for the camera calibration information file.
- `camera_topic` (string, 기본값: `<camera_name>/image`)
- 이미지·정보 데이터를 발행할 토픽 이름.
- `trigger_enable` (bool, default: `false`)
- Enable hardware trigger mode (LINE0). When `true`, frame rate is controlled by the trigger signal.
- `camera_info_url` (string, 기본값: `package://hik_camera_ros2_driver/config/camera_info.yaml`)
- 카메라 캘리브레이션 정보 파일의 URL.
- `use_trigger_timestamp` (bool, default: `false`)
- Use the shared memory timestamp written by the LiDAR driver instead of system time.
- `trigger_enable` (bool, 기본값: `false`)
- 하드웨어 트리거 모드(LINE0)를 켠다. `true`면 프레임률이 트리거 신호로 제어된다.
- `serial_number` (string, default: `""`)
- Select a specific camera by serial number. If empty, the first detected camera is used.
- `use_trigger_timestamp` (bool, 기본값: `false`)
- 시스템 시간 대신, LiDAR 드라이버가 기록한 공유 메모리 타임스탬프를 사용한다.
- `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:
- `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
@@ -157,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
@@ -49,6 +49,12 @@
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 → 평균 적용
@@ -49,6 +49,27 @@
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 → 평균 적용
@@ -49,6 +49,12 @@
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 → 평균 적용
@@ -886,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;
@@ -904,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;