1
0
forked from zbw/yiliao2026

标定+USB摄像头启动

This commit is contained in:
2026-07-12 13:52:32 +08:00
parent b044f6f294
commit f05c427efe
42 changed files with 1259 additions and 9964 deletions

View File

@@ -0,0 +1,36 @@
# vlm_detect 参数配置
# 使用: ros2 launch vlm_detect vlm_detect.launch.py
vlm_node:
ros__parameters:
# VLM 推理服务地址
vlm_host: "http://192.168.10.189:8000"
# 模型名称 (OpenAI 格式)
vlm_model: "./OpenGVLab/InternVL3-1B/"
# 订阅的压缩图像话题
image_topic: "/image_mjpeg"
# 订阅的触发信号话题
trigger_topic: "/sign4return"
# 触发信号值
trigger_sign: 9
# 发布结果的话题
result_topic: "/vlm_result"
# 发送给 VLM 的提示词
prompt_text: "描述图片中有一个病人的特征字数控制在20字以内。"
# 最大输出 token 数
max_tokens: 100
tts_node:
ros__parameters:
# VLM 推理服务地址 (需与 vlm_node 一致)
vlm_host: "http://192.168.10.189:8000"
# 音频输出设备 (PulseAudio sink)
audio_sink: "alsa_output.usb-C-Media_Electronics_Inc._USB_Audio_Device-00.analog-stereo"
# 订阅 VLM 结果的话题 (需与 vlm_node 一致)
result_topic: "/vlm_result"
# TTS 语音 (edge-tts 语音名)
tts_voice: "zh-CN-XiaoxiaoNeural"
# 临时 MP3 存储路径
tmp_mp3_path: "/tmp/tts_out.mp3"
# 播放速度 (ffplay atempo, 范围 0.5~2.0)
tts_speed: 1.5

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
vlm_detect 联合启动文件
同时启动 vlm_node (图生文) 和 tts_node (语音播报)
用法:
ros2 launch vlm_detect vlm_detect.launch.py # 默认配置
ros2 launch vlm_detect vlm_detect.launch.py vlm_host:=http://... # 覆盖 VLM 服务地址
ros2 launch vlm_detect vlm_detect.launch.py use_tts:=false # 只启动 vlm_node
"""
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, LogInfo
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node
def generate_launch_description():
# ==================== Launch 参数 ====================
use_tts = LaunchConfiguration('use_tts')
config_file = LaunchConfiguration('config_file')
# vlm_node 可覆盖参数
vlm_host = LaunchConfiguration('vlm_host')
vlm_model = LaunchConfiguration('vlm_model')
image_topic = LaunchConfiguration('image_topic')
trigger_topic = LaunchConfiguration('trigger_topic')
trigger_sign = LaunchConfiguration('trigger_sign')
result_topic = LaunchConfiguration('result_topic')
prompt_text = LaunchConfiguration('prompt_text')
max_tokens = LaunchConfiguration('max_tokens')
# tts_node 可覆盖参数
audio_sink = LaunchConfiguration('audio_sink')
tts_voice = LaunchConfiguration('tts_voice')
tts_speed = LaunchConfiguration('tts_speed')
# ==================== 声明参数 ====================
declare_use_tts = DeclareLaunchArgument(
'use_tts', default_value='true',
description='是否同时启动 TTS 语音播报节点')
declare_config_file = DeclareLaunchArgument(
'config_file',
default_value=PathJoinSubstitution([
get_package_share_directory('vlm_detect'), 'config', 'vlm_detect.yaml'
]),
description='YAML 配置文件路径')
# vlm_node 参数
declare_vlm_host = DeclareLaunchArgument(
'vlm_host', default_value='http://192.168.10.189:8000',
description='VLM 推理服务地址')
declare_vlm_model = DeclareLaunchArgument(
'vlm_model', default_value='./OpenGVLab/InternVL3-1B/',
description='VLM 模型名称')
declare_image_topic = DeclareLaunchArgument(
'image_topic', default_value='/image_mjpeg',
description='订阅的压缩图像话题')
declare_trigger_topic = DeclareLaunchArgument(
'trigger_topic', default_value='/sign4return',
description='订阅的触发信号话题')
declare_trigger_sign = DeclareLaunchArgument(
'trigger_sign', default_value='9',
description='触发信号值 (Int32)')
declare_result_topic = DeclareLaunchArgument(
'result_topic', default_value='/vlm_result',
description='发布 VLM 结果的话题')
declare_prompt_text = DeclareLaunchArgument(
'prompt_text', default_value='描述图片中有一个病人的特征字数控制在20字以内。',
description='发送给 VLM 的提示词')
declare_max_tokens = DeclareLaunchArgument(
'max_tokens', default_value='100',
description='最大输出 token 数')
# tts_node 参数
declare_audio_sink = DeclareLaunchArgument(
'audio_sink',
default_value='alsa_output.usb-C-Media_Electronics_Inc._USB_Audio_Device-00.analog-stereo',
description='音频输出设备 (PulseAudio sink)')
declare_tts_voice = DeclareLaunchArgument(
'tts_voice', default_value='zh-CN-XiaoxiaoNeural',
description='TTS 语音名称 (edge-tts)')
declare_tts_speed = DeclareLaunchArgument(
'tts_speed', default_value='1.5',
description='播放速度倍率 (0.5~2.0)')
# ==================== 节点 ====================
vlm_node = Node(
package='vlm_detect',
executable='vlm_node',
name='vlm_detect',
output='screen',
parameters=[config_file,
{
'vlm_host': vlm_host,
'vlm_model': vlm_model,
'image_topic': image_topic,
'trigger_topic': trigger_topic,
'trigger_sign': trigger_sign,
'result_topic': result_topic,
'prompt_text': prompt_text,
'max_tokens': max_tokens,
}],
)
tts_node = Node(
package='vlm_detect',
executable='tts_node',
name='tts_node',
output='screen',
condition=IfCondition(use_tts),
parameters=[config_file,
{
'vlm_host': vlm_host,
'audio_sink': audio_sink,
'result_topic': result_topic,
'tts_voice': tts_voice,
'tts_speed': tts_speed,
}],
)
# ==================== 启动描述 ====================
return LaunchDescription([
# 参数声明
declare_use_tts,
declare_config_file,
declare_vlm_host,
declare_vlm_model,
declare_image_topic,
declare_trigger_topic,
declare_trigger_sign,
declare_result_topic,
declare_prompt_text,
declare_max_tokens,
declare_audio_sink,
declare_tts_voice,
declare_tts_speed,
# 节点
LogInfo(msg=['配置文件: ', config_file]),
LogInfo(msg=['VLM 服务: ', vlm_host]),
LogInfo(msg=['TTS 播报: ', use_tts]),
vlm_node,
tts_node,
])

View File

@@ -1,3 +1,5 @@
import os
from glob import glob
from setuptools import find_packages, setup
package_name = 'vlm_detect'
@@ -10,19 +12,21 @@ setup(
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
('share/' + package_name + '/launch', glob('launch/*.launch.py')),
('share/' + package_name + '/config', glob('config/*.yaml')),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='root',
maintainer_email='root@todo.todo',
description='TODO: Package description',
description='VLM 图生文检测 + TTS 语音播报',
license='TODO: License declaration',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'vlm_node = vlm_detect.vlm_node:main',
'console_scripts': [
'vlm_node = vlm_detect.vlm_node:main',
'test_publisher = vlm_detect.test_publisher:main',
'tts_node = vlm_detect.tts_node:main',
],
],
},
)

View File

@@ -1,42 +1,71 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import rclpy, subprocess, requests, os
from rclpy.node import Node
from std_msgs.msg import String
VLM_HOST = "http://192.168.10.173:8000"
# USB Audio Device (Card 1)
AUDIO_SINK = "alsa_output.usb-C-Media_Electronics_Inc._USB_Audio_Device-00.analog-stereo"
AUDIO_ENV = {**os.environ, "PULSE_SINK": AUDIO_SINK}
class TTSNode(Node):
def __init__(self):
super().__init__("tts_node")
self.sub = self.create_subscription(String, "/vlm_result", self.callback, 10)
self.get_logger().info("TTS 播报节点已启动 (USB Audio Device, edge-tts 自然语音)")
self.declare_parameter('vlm_host', 'http://192.168.10.189:8000')
self.declare_parameter('audio_sink',
'alsa_output.usb-C-Media_Electronics_Inc._USB_Audio_Device-00.analog-stereo')
self.declare_parameter('result_topic', '/vlm_result')
self.declare_parameter('tts_voice', 'zh-CN-XiaoxiaoNeural')
self.declare_parameter('tmp_mp3_path', '/tmp/tts_out.mp3')
self.declare_parameter('tts_speed', 1.5)
self.vlm_host = self.get_parameter('vlm_host').value
audio_sink = self.get_parameter('audio_sink').value
result_topic = self.get_parameter('result_topic').value
self.tts_voice = self.get_parameter('tts_voice').value
self.tmp_mp3 = self.get_parameter('tmp_mp3_path').value
self.tts_speed = self.get_parameter('tts_speed').value
self.audio_env = {**os.environ, "PULSE_SINK": audio_sink}
self.sub = self.create_subscription(String, result_topic, self.callback, 10)
self.get_logger().info(
f"TTS 节点启动 | host={self.vlm_host} | sink={audio_sink} | "
f"voice={self.tts_voice} | speed={self.tts_speed}x"
)
def callback(self, msg):
text = msg.data
self.get_logger().info(f"播报: {text}")
self.get_logger().info(f"语音播报: {text}")
try:
resp = requests.post(f"{VLM_HOST}/v1/tts",
json={"text": text, "voice": "zh-CN-XiaoxiaoNeural"}, timeout=60)
mp3 = "/tmp/tts_out.mp3"
with open(mp3, "wb") as f:
resp = requests.post(
f"{self.vlm_host}/v1/tts",
json={"text": text, "voice": self.tts_voice},
timeout=60
)
resp.raise_for_status()
with open(self.tmp_mp3, "wb") as f:
f.write(resp.content)
subprocess.Popen(["ffplay", "-nodisp", "-autoexit", mp3],
speed_str = f"atempo={self.tts_speed}"
subprocess.Popen(
["ffplay", "-nodisp", "-autoexit", "-af", speed_str, self.tmp_mp3],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=AUDIO_ENV)
env=self.audio_env
)
except Exception as e:
self.get_logger().error(f"TTS 失败,降级 espeak: {e}")
subprocess.Popen(["espeak-ng", "-v", "zh", "-s", "150", text],
env=AUDIO_ENV)
self.get_logger().error(f"TTS 失败, 降级 espeak: {e}")
subprocess.Popen(
["espeak-ng", "-v", "zh", "-s", "150", text],
env=self.audio_env
)
def main(args=None):
rclpy.init(args=args)
node = TTSNode()
try: rclpy.spin(node)
except KeyboardInterrupt: pass
finally: node.destroy_node(); rclpy.shutdown()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()

View File

@@ -1,146 +1,128 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import rclpy
from rclpy.node import Node
from std_msgs.msg import Int32, String
from sensor_msgs.msg import CompressedImage
from cv_bridge import CvBridge
import cv2
import base64
import threading
from openai import OpenAI
import os
import time
import numpy as np
class VLMProcessor(Node):
def __init__(self):
super().__init__('vlm_detect')
# 初始化 OpenAI 客户端
self.client = OpenAI(
base_url="http://192.168.10.173:8000/v1", # 本地 API 地址
api_key="EMPTY", # 不需要真实 API key
)
# ROS2 组件
self.bridge = CvBridge()
self.latest_image = None
self.image_lock = threading.Lock()
# 订阅图像话题
self.image_sub = self.create_subscription(
CompressedImage,
'/image_mjpeg',
self.image_callback,
10
)
# 订阅触发信号
self.sign_sub = self.create_subscription(
Int32,
'/sign4return',
self.sign_callback,
10
)
# 发布结果
self.result_pub = self.create_publisher(
String,
'/vlm_result',
10
)
self.get_logger().info("VLM Processor...")
def image_callback(self, msg):
"""保存最新的图像"""
with self.image_lock:
try:
# bridge = CvBridge()
np_arr = np.frombuffer(msg.data, np.uint8)
# 使用 OpenCV 解码图像
cv_image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
# cv_image =bridge.imgmsg_to_cv2(msg,desired_encoding='bgr8')
self.latest_image = cv_image
self.get_logger().debug("recive picture")
except Exception as e:
self.get_logger().error(f"err picture: {e}")
def sign_callback(self, msg):
"""处理触发信号"""
if msg.data == 9:
self.get_logger().info(f"收到触发信号 ({msg.data}),开始处理图像...")
# 检查是否有可用图像
with self.image_lock:
if self.latest_image is None:
self.get_logger().warning("没有可用图像")
return
# 保存临时图像文件
temp_path = "/tmp/vlm_temp_image.jpg"
cv2.imwrite(temp_path, self.latest_image)
self.get_logger().info(f"已保存临时图像: {temp_path}")
# 处理图像
try:
description = self.process_image(temp_path)
self.get_logger().info(f"图像描述结果: {description}")
# 发布结果
result_msg = String()
result_msg.data = description
self.result_pub.publish(result_msg)
# 清理临时文件
os.remove(temp_path)
except Exception as e:
self.get_logger().error(f"处理图像时出错: {e}")
def process_image(self, image_path):
"""使用 VLM 模型处理图像"""
# 读取并编码图像
with open(image_path, "rb") as image_file:
base64_image = base64.b64encode(image_file.read()).decode('utf-8')
# 发送请求到 VLM 模型
start_time = time.time()
response = self.client.chat.completions.create(
model="./OpenGVLab/InternVL3-1B/",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "描述图片中有一个病人的特征字数控制在20字以内。"},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
},
},
]
}
],
max_tokens=100,
)
processing_time = time.time() - start_time
self.get_logger().info(f"VLM 处理耗时 {processing_time:.1f}s")
return response.choices[0].message.content
def main(args=None):
rclpy.init(args=args)
node = VLMProcessor()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import rclpy
from rclpy.node import Node
from std_msgs.msg import Int32, String
from sensor_msgs.msg import CompressedImage
from cv_bridge import CvBridge
import cv2
import base64
import threading
from openai import OpenAI
import os
import time
import numpy as np
class VLMProcessor(Node):
def __init__(self):
super().__init__('vlm_detect')
# 声明 ROS2 参数
self.declare_parameter('vlm_host', 'http://192.168.10.189:8000')
self.declare_parameter('vlm_model', './OpenGVLab/InternVL3-1B/')
self.declare_parameter('image_topic', '/image_mjpeg')
self.declare_parameter('trigger_topic', '/sign4return')
self.declare_parameter('trigger_sign', 9)
self.declare_parameter('result_topic', '/vlm_result')
self.declare_parameter('prompt_text', '描述图片中有一个病人的特征字数控制在20字以内。')
self.declare_parameter('max_tokens', 100)
vlm_host = self.get_parameter('vlm_host').value
vlm_model = self.get_parameter('vlm_model').value
image_topic = self.get_parameter('image_topic').value
trigger_topic = self.get_parameter('trigger_topic').value
self.trigger_sign = self.get_parameter('trigger_sign').value
result_topic = self.get_parameter('result_topic').value
self.prompt_text = self.get_parameter('prompt_text').value
self.max_tokens = self.get_parameter('max_tokens').value
# 初始化 OpenAI 客户端
self.client = OpenAI(
base_url=f"{vlm_host}/v1",
api_key="EMPTY",
)
self.vlm_model = vlm_model
# ROS2 组件
self.bridge = CvBridge()
self.latest_image = None
self.image_lock = threading.Lock()
self.image_sub = self.create_subscription(
CompressedImage, image_topic, self.image_callback, 10
)
self.sign_sub = self.create_subscription(
Int32, trigger_topic, self.sign_callback, 10
)
self.result_pub = self.create_publisher(String, result_topic, 10)
self.get_logger().info(
f"VLM Processor 启动 | host={vlm_host} | model={vlm_model} | "
f"image={image_topic} | trigger={trigger_topic}(sign={self.trigger_sign})"
)
def image_callback(self, msg):
with self.image_lock:
try:
np_arr = np.frombuffer(msg.data, np.uint8)
cv_image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
self.latest_image = cv_image
self.get_logger().debug("图片已接收")
except Exception as e:
self.get_logger().error(f"图片接收错误: {e}")
def sign_callback(self, msg):
if msg.data == self.trigger_sign:
self.get_logger().info(f"收到触发信号 ({msg.data}), 开始处理...")
with self.image_lock:
if self.latest_image is None:
self.get_logger().warning("无可用图片")
return
temp_path = "/tmp/vlm_temp_image.jpg"
cv2.imwrite(temp_path, self.latest_image)
self.get_logger().info(f"临时图片已保存: {temp_path}")
try:
description = self.process_image(temp_path)
self.get_logger().info(f"图像描述: {description}")
result_msg = String()
result_msg.data = description
self.result_pub.publish(result_msg)
os.remove(temp_path)
except Exception as e:
self.get_logger().error(f"图像处理出错: {e}")
def process_image(self, image_path):
with open(image_path, "rb") as image_file:
base64_image = base64.b64encode(image_file.read()).decode('utf-8')
start_time = time.time()
response = self.client.chat.completions.create(
model=self.vlm_model,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": self.prompt_text},
{"type": "image_url", "image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}},
]
}],
max_tokens=self.max_tokens,
)
self.get_logger().info(f"VLM 推理耗时 {time.time() - start_time:.1f}s")
return response.choices[0].message.content
def main(args=None):
rclpy.init(args=args)
node = VLMProcessor()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()