자동 게인, 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>
This commit is contained in:
Dongubak
2026-08-17 00:04:03 +09:00
parent 5a587b0149
commit 0e98a96db2
9 changed files with 647 additions and 0 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',
],
},
)
@@ -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;