refactor: remove HTTP image preview from web_node, keep API only
This commit is contained in:
@@ -26,8 +26,8 @@
|
|||||||
│ │ application_pkg │ │
|
│ │ application_pkg │ │
|
||||||
│ │ ┌───────────────────────────────┐ │ │
|
│ │ ┌───────────────────────────────┐ │ │
|
||||||
│ │ │ web_node (:8080) │ │ │
|
│ │ │ web_node (:8080) │ │ │
|
||||||
│ │ │ - MJPEG 预览页 │ │ │
|
|
||||||
│ │ │ - POST /api/mode/switch │──┼──▶ /gimbal/mode │
|
│ │ │ - POST /api/mode/switch │──┼──▶ /gimbal/mode │
|
||||||
|
│ │ │ - POST /api/laser │ │ │
|
||||||
│ │ └───────────────────────────────┘ │ │
|
│ │ └───────────────────────────────┘ │ │
|
||||||
│ └─────────────────────────────────────┘ │
|
│ └─────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
@@ -50,8 +50,6 @@
|
|||||||
| 阶段 | 话题 | 方向 | 消息类型 | 说明 |
|
| 阶段 | 话题 | 方向 | 消息类型 | 说明 |
|
||||||
|------|------|------|----------|------|
|
|------|------|------|----------|------|
|
||||||
| 图像采集 | `/camera/image_raw` | camera_node → vision_node | `sensor_msgs/Image` | 原始相机图像 |
|
| 图像采集 | `/camera/image_raw` | camera_node → vision_node | `sensor_msgs/Image` | 原始相机图像 |
|
||||||
| 视觉结果 | `/vision/result_image` | vision_node → web_node | `sensor_msgs/Image` | 带轮廓标注的结果图 |
|
|
||||||
| 二值调试 | `/vision/binary_image` | vision_node → web_node | `sensor_msgs/Image` | 二值化调试图像 |
|
|
||||||
| 目标追踪 | `/vision/target` | vision_node → gimbal_node | `geometry_msgs/Point` | 归一化目标偏移 (x,y) |
|
| 目标追踪 | `/vision/target` | vision_node → gimbal_node | `geometry_msgs/Point` | 归一化目标偏移 (x,y) |
|
||||||
| 模式切换 | `/gimbal/mode` | web_node → gimbal_node | `std_msgs/String` | auto/manual/debug |
|
| 模式切换 | `/gimbal/mode` | web_node → gimbal_node | `std_msgs/String` | auto/manual/debug |
|
||||||
| 手动控制 | `/cmd_gimbal` | 前端 → gimbal_node | `geometry_msgs/Twist` | yaw/pitch 角度 |
|
| 手动控制 | `/cmd_gimbal` | 前端 → gimbal_node | `geometry_msgs/Twist` | yaw/pitch 角度 |
|
||||||
@@ -88,9 +86,6 @@ source install/setup.bash
|
|||||||
|
|
||||||
# 启动全流程
|
# 启动全流程
|
||||||
ros2 launch bringup_pkg vision_pipeline.launch.py
|
ros2 launch bringup_pkg vision_pipeline.launch.py
|
||||||
|
|
||||||
# 浏览器预览
|
|
||||||
# http://<设备IP>:8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
详细使用方法见 [使用指南](docs/使用指南.md)。
|
详细使用方法见 [使用指南](docs/使用指南.md)。
|
||||||
|
|||||||
+3
-9
@@ -160,13 +160,7 @@ ros2 topic pub /pid_config std_msgs/String \
|
|||||||
|
|
||||||
参数立即生效,调试完毕后切回 auto 或 manual 模式。
|
参数立即生效,调试完毕后切回 auto 或 manual 模式。
|
||||||
|
|
||||||
## 9. 查看视觉结果
|
## 9. 查看话题
|
||||||
|
|
||||||
浏览器打开 `http://<设备IP>:8080`,可看到:
|
|
||||||
- 原始图像 + 轮廓标注(result)
|
|
||||||
- Otsu 二值化 + 闭运算结果(binary)
|
|
||||||
|
|
||||||
## 10. 查看话题
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 列出所有活跃话题
|
# 列出所有活跃话题
|
||||||
@@ -180,7 +174,7 @@ ros2 topic hz /vision/target
|
|||||||
ros2 topic echo /vision/target
|
ros2 topic echo /vision/target
|
||||||
```
|
```
|
||||||
|
|
||||||
## 11. 测试
|
## 10. 测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd workspace
|
cd workspace
|
||||||
@@ -195,7 +189,7 @@ colcon test --packages-select gimbal_control_pkg
|
|||||||
colcon test-result --all
|
colcon test-result --all
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. 故障排查
|
## 11. 故障排查
|
||||||
|
|
||||||
| 现象 | 检查项 |
|
| 现象 | 检查项 |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
|
|||||||
@@ -3,88 +3,20 @@ import json
|
|||||||
import socket
|
import socket
|
||||||
import select
|
import select
|
||||||
import socketserver
|
import socketserver
|
||||||
import cv2
|
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
from sensor_msgs.msg import Image
|
|
||||||
from std_msgs.msg import String
|
from std_msgs.msg import String
|
||||||
from cv_bridge import CvBridge
|
|
||||||
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
_latest_jpeg = None
|
|
||||||
|
|
||||||
_HTML_PAGE = """<!DOCTYPE html>
|
|
||||||
<html lang="zh">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Vision Pipeline</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: 'Segoe UI', sans-serif; margin: 0; padding: 20px;
|
|
||||||
background: #0d1117; color: #c9d1d9; }
|
|
||||||
h1 { text-align: center; color: #58a6ff; font-size: 1.5em; margin-bottom: 20px; }
|
|
||||||
.container { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; }
|
|
||||||
.panel { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
|
||||||
padding: 12px; text-align: center; }
|
|
||||||
.panel h2 { margin: 0 0 10px 0; font-size: 1em; color: #8b949e; }
|
|
||||||
.panel img { max-width: 45vw; max-height: 50vh; border-radius: 4px; display: block; }
|
|
||||||
#status { text-align: center; color: #3fb950; font-size: 0.9em; margin-top: 12px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Vision Pipeline Results</h1>
|
|
||||||
<div class="container">
|
|
||||||
<div class="panel">
|
|
||||||
<h2>Result Image (contours)</h2>
|
|
||||||
<img id="result" src="/result" alt="Result">
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<h2>Binary (Otsu + Close)</h2>
|
|
||||||
<img id="binary" src="/binary" alt="Binary">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="status">Live</div>
|
|
||||||
<script>
|
|
||||||
const ts = () => '?t=' + Date.now();
|
|
||||||
setInterval(() => { document.getElementById('result').src = '/result' + ts(); }, 150);
|
|
||||||
setInterval(() => { document.getElementById('binary').src = '/binary' + ts(); }, 150);
|
|
||||||
let lastChange = Date.now();
|
|
||||||
const observer = new MutationObserver(() => { lastChange = Date.now(); });
|
|
||||||
observer.observe(document.getElementById('result'), { attributes: true });
|
|
||||||
setInterval(() => {
|
|
||||||
const elapsed = (Date.now() - lastChange) / 1000;
|
|
||||||
const el = document.getElementById('status');
|
|
||||||
if (elapsed > 3) {
|
|
||||||
el.textContent = 'No new frames (' + elapsed.toFixed(0) + 's)';
|
|
||||||
el.style.color = '#f85149';
|
|
||||||
} else {
|
|
||||||
el.textContent = 'Live';
|
|
||||||
el.style.color = '#3fb950';
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
_mode_publisher = None
|
_mode_publisher = None
|
||||||
|
|
||||||
|
|
||||||
class _ImageHandler(BaseHTTPRequestHandler):
|
class _APIHandler(BaseHTTPRequestHandler):
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
path = self.path.split('?')[0]
|
self.send_response(404)
|
||||||
if path == '/' or path == '/index.html':
|
self.end_headers()
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-type', 'text/html; charset=utf-8')
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(_HTML_PAGE.encode())
|
|
||||||
elif path in ('/result', '/binary'):
|
|
||||||
self._serve_image(path.lstrip('/'))
|
|
||||||
else:
|
|
||||||
self.send_response(404)
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
path = self.path.split('?')[0]
|
path = self.path.split('?')[0]
|
||||||
@@ -160,22 +92,6 @@ class _ImageHandler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(f'{{"status":"ok","laser":"{action}"}}'.encode())
|
self.wfile.write(f'{{"status":"ok","laser":"{action}"}}'.encode())
|
||||||
|
|
||||||
def _serve_image(self, key):
|
|
||||||
global _latest_jpeg
|
|
||||||
jpeg = _latest_jpeg.get(key) if _latest_jpeg else None
|
|
||||||
if jpeg is None:
|
|
||||||
self.send_response(204)
|
|
||||||
self.end_headers()
|
|
||||||
return
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-type', 'image/jpeg')
|
|
||||||
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
|
||||||
self.end_headers()
|
|
||||||
try:
|
|
||||||
self.wfile.write(jpeg)
|
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def handle_error(self, request, client_address):
|
def handle_error(self, request, client_address):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -191,56 +107,32 @@ class WebNode(Node):
|
|||||||
def __init__(self, port=8080):
|
def __init__(self, port=8080):
|
||||||
super().__init__('web_node')
|
super().__init__('web_node')
|
||||||
global _mode_publisher
|
global _mode_publisher
|
||||||
self._bridge = CvBridge()
|
|
||||||
|
|
||||||
self._result_sub = self.create_subscription(
|
|
||||||
Image, '/vision/result_image', self._make_jpeg_callback('result'), 10
|
|
||||||
)
|
|
||||||
self._binary_sub = self.create_subscription(
|
|
||||||
Image, '/vision/binary_image', self._make_jpeg_callback('binary'), 10
|
|
||||||
)
|
|
||||||
|
|
||||||
self._mode_publisher = self.create_publisher(
|
self._mode_publisher = self.create_publisher(
|
||||||
String, '/gimbal/mode', 10
|
String, '/gimbal/mode', 10
|
||||||
)
|
)
|
||||||
_mode_publisher = self._mode_publisher
|
_mode_publisher = self._mode_publisher
|
||||||
|
|
||||||
self._server = _ThreadingHTTPServer(('', port), _ImageHandler)
|
self._server = _ThreadingHTTPServer(('', port), _APIHandler)
|
||||||
self._server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
self._server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
self._server.socket.setblocking(False)
|
self._server.socket.setblocking(False)
|
||||||
self._server.timeout = 0
|
self._server.timeout = 0
|
||||||
|
|
||||||
# 定时器在主线程中轮询 accept,连接交给工作线程处理
|
|
||||||
self._timer = self.create_timer(0.01, self._handle_http_requests)
|
self._timer = self.create_timer(0.01, self._handle_http_requests)
|
||||||
|
|
||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
ip = socket.gethostbyname(hostname)
|
ip = socket.gethostbyname(hostname)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Web server on http://{ip}:{port} (http://localhost:{port})'
|
f'API server on http://{ip}:{port}'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _handle_http_requests(self):
|
def _handle_http_requests(self):
|
||||||
# 一次 tick 内处理所有积压连接
|
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
try:
|
try:
|
||||||
self._server.handle_request()
|
self._server.handle_request()
|
||||||
except Exception:
|
except Exception:
|
||||||
break
|
break
|
||||||
|
|
||||||
def _make_jpeg_callback(self, key):
|
|
||||||
def callback(msg):
|
|
||||||
global _latest_jpeg
|
|
||||||
try:
|
|
||||||
cv_image = self._bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
|
|
||||||
_, jpeg = cv2.imencode('.jpg', cv_image,
|
|
||||||
[cv2.IMWRITE_JPEG_QUALITY, 85])
|
|
||||||
if _latest_jpeg is None:
|
|
||||||
_latest_jpeg = {}
|
|
||||||
_latest_jpeg[key] = jpeg.tobytes()
|
|
||||||
except Exception as e:
|
|
||||||
self.get_logger().error(f'JPEG encode error ({key}): {e}')
|
|
||||||
return callback
|
|
||||||
|
|
||||||
def destroy_node(self):
|
def destroy_node(self):
|
||||||
self._server.shutdown()
|
self._server.shutdown()
|
||||||
super().destroy_node()
|
super().destroy_node()
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
<license>TODO: License declaration</license>
|
<license>TODO: License declaration</license>
|
||||||
|
|
||||||
<depend>rclpy</depend>
|
<depend>rclpy</depend>
|
||||||
<depend>sensor_msgs</depend>
|
|
||||||
<depend>cv_bridge</depend>
|
|
||||||
<depend>std_msgs</depend>
|
<depend>std_msgs</depend>
|
||||||
|
|
||||||
<test_depend>ament_copyright</test_depend>
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
|||||||
Reference in New Issue
Block a user