Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e98a96db2 | |||
| 5a587b0149 |
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user