forked from zbw/yiliao2026
编写了一些脚本和工具-zbw
This commit is contained in:
12
README.md
12
README.md
@@ -88,8 +88,18 @@
|
|||||||
`ros2 launch lslidar_driver lsn10_launch.py `
|
`ros2 launch lslidar_driver lsn10_launch.py `
|
||||||
open new terminal
|
open new terminal
|
||||||
```bash
|
```bash
|
||||||
|
# 简洁前:
|
||||||
ros2 topic pub -1 /lslidar_order std_msgs/msg/Int8 data:\ 1\ # (open radar)
|
ros2 topic pub -1 /lslidar_order std_msgs/msg/Int8 data:\ 1\ # (open radar)
|
||||||
ros2 topic pub -1 /lslidar_order std_msgs/msg/Int8 data:\ 0\ # (close radar)
|
ros2 topic pub -1 /lslidar_order std_msgs/msg/Int8 data:\ 0\ # (close radar)
|
||||||
|
|
||||||
|
# 简洁后:
|
||||||
|
lidar
|
||||||
|
```
|
||||||
|
|
||||||
|
启动qr_detect的方式:
|
||||||
|
```bash
|
||||||
|
ros2 launch car_usb_cam hobot_usb_cam.launch.py # 终端1
|
||||||
|
ros2 launch qr_detection qr_detect.launch.py # 终端2
|
||||||
```
|
```
|
||||||
|
|
||||||
# 四、其它说明
|
# 四、其它说明
|
||||||
@@ -105,6 +115,8 @@ foxglove # 启动foxbridge
|
|||||||
运行二维码检测(无缓存版):
|
运行二维码检测(无缓存版):
|
||||||
`ros2 run qr_detection qr_dete_depth_node --ros-args -p use_buffer:=false`
|
`ros2 run qr_detection qr_dete_depth_node --ros-args -p use_buffer:=false`
|
||||||
|
|
||||||
|
二维码识别出来是一个四位数,偶数为逆,奇数为顺
|
||||||
|
|
||||||
# 五、tools 工具使用
|
# 五、tools 工具使用
|
||||||
`measure_turning_radius.py`:功能为测试实际转向角度与输入的关系。启动后,会在/turning_radius_data文件夹下生成测试数据
|
`measure_turning_radius.py`:功能为测试实际转向角度与输入的关系。启动后,会在/turning_radius_data文件夹下生成测试数据
|
||||||
`set_volume.py`:功能为设置USB声卡的音量,修改第七行或直接`python3 set_volume.py 80`即可更改为80(或任何你想要的数字)
|
`set_volume.py`:功能为设置USB声卡的音量,修改第七行或直接`python3 set_volume.py 80`即可更改为80(或任何你想要的数字)
|
||||||
|
|||||||
BIN
src/car_usb_cam/__pycache__/tf_web_bridge.cpython-310.pyc
Normal file
BIN
src/car_usb_cam/__pycache__/tf_web_bridge.cpython-310.pyc
Normal file
Binary file not shown.
480
src/car_usb_cam/image_web_bridge.py
Normal file
480
src/car_usb_cam/image_web_bridge.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ROS2 image topic to MJPEG web bridge.
|
||||||
|
|
||||||
|
Supports the image publishing modes used by car_usb_cam:
|
||||||
|
- sensor_msgs/msg/Image on /image
|
||||||
|
- sensor_msgs/msg/CompressedImage style JPEG topics
|
||||||
|
- hbm_img_msgs/msg/HbmMsg1080P/540P/480P zero-copy payload messages
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
JPEG_LOCK = threading.Condition()
|
||||||
|
LATEST_JPEG: bytes | None = None
|
||||||
|
LATEST_META: dict[str, Any] = {
|
||||||
|
"ok": False,
|
||||||
|
"message": "ROS thread not started",
|
||||||
|
"topic": None,
|
||||||
|
"topic_type": None,
|
||||||
|
"frames": 0,
|
||||||
|
"fps": 0.0,
|
||||||
|
"last_frame_time": None,
|
||||||
|
"compress": False,
|
||||||
|
"quality": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_encoding(value: Any) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip("\x00").lower()
|
||||||
|
if isinstance(value, (bytes, bytearray)):
|
||||||
|
return bytes(value).split(b"\x00", 1)[0].decode("ascii", errors="ignore").lower()
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return bytes(int(item) & 0xFF for item in value).split(b"\x00", 1)[0].decode(
|
||||||
|
"ascii", errors="ignore"
|
||||||
|
).lower()
|
||||||
|
return str(value).strip("\x00").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_bytes(data: Any, size: int | None = None) -> bytes:
|
||||||
|
if isinstance(data, bytes):
|
||||||
|
raw = data
|
||||||
|
elif isinstance(data, bytearray):
|
||||||
|
raw = bytes(data)
|
||||||
|
else:
|
||||||
|
raw = bytes(int(item) & 0xFF for item in data)
|
||||||
|
return raw[:size] if size is not None else raw
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_from_buffer(raw: bytes, height: int, step: int, row_bytes: int) -> np.ndarray:
|
||||||
|
expected = height * step
|
||||||
|
if len(raw) < expected:
|
||||||
|
raise ValueError(f"image data too short: got {len(raw)} bytes, need {expected}")
|
||||||
|
rows = np.frombuffer(raw[:expected], dtype=np.uint8).reshape((height, step))
|
||||||
|
return rows[:, :row_bytes]
|
||||||
|
|
||||||
|
|
||||||
|
def image_message_to_bgr(msg: Any) -> np.ndarray:
|
||||||
|
height = int(msg.height)
|
||||||
|
width = int(msg.width)
|
||||||
|
step = int(msg.step)
|
||||||
|
encoding = normalize_encoding(msg.encoding)
|
||||||
|
raw = _valid_bytes(msg.data)
|
||||||
|
|
||||||
|
if encoding in ("rgb8", "bgr8"):
|
||||||
|
rows = _rows_from_buffer(raw, height, step, width * 3)
|
||||||
|
image = rows.reshape((height, width, 3))
|
||||||
|
return cv2.cvtColor(image, cv2.COLOR_RGB2BGR) if encoding == "rgb8" else image
|
||||||
|
|
||||||
|
if encoding in ("mono8", "8uc1"):
|
||||||
|
rows = _rows_from_buffer(raw, height, step, width)
|
||||||
|
return cv2.cvtColor(rows.reshape((height, width)), cv2.COLOR_GRAY2BGR)
|
||||||
|
|
||||||
|
if encoding in ("rgba8", "bgra8"):
|
||||||
|
rows = _rows_from_buffer(raw, height, step, width * 4)
|
||||||
|
image = rows.reshape((height, width, 4))
|
||||||
|
code = cv2.COLOR_RGBA2BGR if encoding == "rgba8" else cv2.COLOR_BGRA2BGR
|
||||||
|
return cv2.cvtColor(image, code)
|
||||||
|
|
||||||
|
if encoding in ("yuyv", "yuyv2rgb", "yuv422_yuy2"):
|
||||||
|
rows = _rows_from_buffer(raw, height, step, width * 2)
|
||||||
|
image = rows.reshape((height, width, 2))
|
||||||
|
return cv2.cvtColor(image, cv2.COLOR_YUV2BGR_YUY2)
|
||||||
|
|
||||||
|
if encoding == "nv12":
|
||||||
|
expected = height * width * 3 // 2
|
||||||
|
if len(raw) < expected:
|
||||||
|
raise ValueError(f"nv12 data too short: got {len(raw)} bytes, need {expected}")
|
||||||
|
image = np.frombuffer(raw[:expected], dtype=np.uint8).reshape((height * 3 // 2, width))
|
||||||
|
return cv2.cvtColor(image, cv2.COLOR_YUV2BGR_NV12)
|
||||||
|
|
||||||
|
raise ValueError(f"unsupported image encoding: {encoding or '<empty>'}")
|
||||||
|
|
||||||
|
|
||||||
|
def hbm_message_to_bgr(msg: Any) -> np.ndarray:
|
||||||
|
data_size = int(getattr(msg, "data_size", 0)) or None
|
||||||
|
image_like = type(
|
||||||
|
"ImageLike",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"height": int(msg.height),
|
||||||
|
"width": int(msg.width),
|
||||||
|
"step": int(msg.step),
|
||||||
|
"encoding": normalize_encoding(msg.encoding),
|
||||||
|
"data": _valid_bytes(msg.data, data_size),
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
return image_message_to_bgr(image_like)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_bgr_to_jpeg(image: np.ndarray, quality: int = 75) -> bytes:
|
||||||
|
quality = max(1, min(100, int(quality)))
|
||||||
|
ok, encoded = cv2.imencode(".jpg", image, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
|
||||||
|
if not ok:
|
||||||
|
raise ValueError("cv2 failed to encode jpeg")
|
||||||
|
return encoded.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def compressed_message_to_jpeg(msg: Any, quality: int = 75, force_compress: bool = False) -> bytes:
|
||||||
|
data = _valid_bytes(msg.data)
|
||||||
|
fmt = str(getattr(msg, "format", "")).lower()
|
||||||
|
is_jpeg = "jpeg" in fmt or "jpg" in fmt or data.startswith(b"\xff\xd8")
|
||||||
|
if is_jpeg and not force_compress:
|
||||||
|
return data
|
||||||
|
array = np.frombuffer(data, dtype=np.uint8)
|
||||||
|
decoded = cv2.imdecode(array, cv2.IMREAD_COLOR)
|
||||||
|
if decoded is None:
|
||||||
|
raise ValueError(f"unsupported compressed image format: {fmt or '<empty>'}")
|
||||||
|
return encode_bgr_to_jpeg(decoded, quality)
|
||||||
|
|
||||||
|
|
||||||
|
def message_to_jpeg(
|
||||||
|
msg: Any,
|
||||||
|
topic_type: str,
|
||||||
|
quality: int = 75,
|
||||||
|
force_compress: bool = False,
|
||||||
|
) -> bytes:
|
||||||
|
if topic_type == "sensor_msgs/msg/CompressedImage":
|
||||||
|
return compressed_message_to_jpeg(msg, quality, force_compress)
|
||||||
|
if topic_type == "sensor_msgs/msg/Image":
|
||||||
|
return encode_bgr_to_jpeg(image_message_to_bgr(msg), quality)
|
||||||
|
if topic_type.startswith("hbm_img_msgs/msg/HbmMsg"):
|
||||||
|
return encode_bgr_to_jpeg(hbm_message_to_bgr(msg), quality)
|
||||||
|
raise ValueError(f"unsupported topic type: {topic_type}")
|
||||||
|
|
||||||
|
|
||||||
|
class FrameRateTracker:
|
||||||
|
def __init__(self, window_seconds: float = 2.0) -> None:
|
||||||
|
self.window_seconds = float(window_seconds)
|
||||||
|
self.samples: deque[float] = deque()
|
||||||
|
|
||||||
|
def record(self, timestamp: float | None = None) -> float:
|
||||||
|
now = time.time() if timestamp is None else float(timestamp)
|
||||||
|
self.samples.append(now)
|
||||||
|
oldest = now - self.window_seconds
|
||||||
|
while self.samples and self.samples[0] < oldest:
|
||||||
|
self.samples.popleft()
|
||||||
|
if len(self.samples) < 2:
|
||||||
|
return 0.0
|
||||||
|
elapsed = self.samples[-1] - self.samples[0]
|
||||||
|
if elapsed <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
return round((len(self.samples) - 1) / elapsed, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_frame(jpeg: bytes, topic: str, topic_type: str, fps: float) -> None:
|
||||||
|
with JPEG_LOCK:
|
||||||
|
global LATEST_JPEG
|
||||||
|
LATEST_JPEG = jpeg
|
||||||
|
LATEST_META.update(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": "streaming",
|
||||||
|
"topic": topic,
|
||||||
|
"topic_type": topic_type,
|
||||||
|
"frames": int(LATEST_META.get("frames") or 0) + 1,
|
||||||
|
"fps": float(fps),
|
||||||
|
"last_frame_time": time.time(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
JPEG_LOCK.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
def update_status(ok: bool, message: str, **extra: Any) -> None:
|
||||||
|
with JPEG_LOCK:
|
||||||
|
LATEST_META.update({"ok": ok, "message": message, **extra})
|
||||||
|
JPEG_LOCK.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
def request_path(raw_path: str) -> str:
|
||||||
|
return urlsplit(raw_path).path
|
||||||
|
|
||||||
|
|
||||||
|
def request_query(raw_path: str) -> dict[str, list[str]]:
|
||||||
|
return parse_qs(urlsplit(raw_path).query)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "ImageWebBridge/1.0"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: Any) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
path = request_path(self.path)
|
||||||
|
if path in ("/", "/index.html"):
|
||||||
|
self.serve_index()
|
||||||
|
elif path == "/stream.mjpg":
|
||||||
|
self.serve_stream()
|
||||||
|
elif path == "/snapshot.jpg":
|
||||||
|
self.serve_snapshot()
|
||||||
|
elif path == "/status":
|
||||||
|
self.serve_json(LATEST_META)
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def serve_index(self) -> None:
|
||||||
|
topic = LATEST_META.get("topic") or ""
|
||||||
|
body = f"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ROS Image Bridge</title>
|
||||||
|
<style>
|
||||||
|
html, body {{ margin: 0; height: 100%; background: #111; color: #eee; font-family: Arial, sans-serif; }}
|
||||||
|
main {{ height: 100%; display: grid; grid-template-rows: auto 1fr; }}
|
||||||
|
header {{ display: flex; gap: 18px; align-items: center; padding: 10px 14px; background: #1d1f23; font-size: 14px; }}
|
||||||
|
img {{ width: 100%; height: 100%; object-fit: contain; background: #050505; }}
|
||||||
|
code {{ color: #9ad; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<strong>ROS Image Bridge</strong>
|
||||||
|
<span>topic <code id="topic">{topic}</code></span>
|
||||||
|
<span id="status">connecting</span>
|
||||||
|
</header>
|
||||||
|
<img src="/stream.mjpg" alt="ROS image stream">
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
async function tick() {{
|
||||||
|
const res = await fetch('/status', {{ cache: 'no-store' }});
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('topic').textContent = data.topic || '';
|
||||||
|
const fps = Number(data.fps || 0).toFixed(1);
|
||||||
|
document.getElementById('status').textContent =
|
||||||
|
(data.ok ? 'online' : 'waiting') + ' | fps ' + fps +
|
||||||
|
' | frames ' + (data.frames || 0) + ' | ' + data.message;
|
||||||
|
}}
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
tick();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>""".encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def serve_json(self, data: Any) -> None:
|
||||||
|
body = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def serve_snapshot(self) -> None:
|
||||||
|
with JPEG_LOCK:
|
||||||
|
if LATEST_JPEG is None:
|
||||||
|
self.send_error(503, "no frame yet")
|
||||||
|
return
|
||||||
|
jpeg = LATEST_JPEG
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "image/jpeg")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", str(len(jpeg)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(jpeg)
|
||||||
|
|
||||||
|
def serve_stream(self) -> None:
|
||||||
|
boundary = "frame"
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", f"multipart/x-mixed-replace; boundary={boundary}")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
last_frame: bytes | None = None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
with JPEG_LOCK:
|
||||||
|
JPEG_LOCK.wait_for(
|
||||||
|
lambda: LATEST_JPEG is not None and LATEST_JPEG is not last_frame,
|
||||||
|
timeout=2.0,
|
||||||
|
)
|
||||||
|
jpeg = LATEST_JPEG
|
||||||
|
if jpeg is None or jpeg is last_frame:
|
||||||
|
self.wfile.write(b"\r\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
continue
|
||||||
|
last_frame = jpeg
|
||||||
|
header = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
"Content-Type: image/jpeg\r\n"
|
||||||
|
f"Content-Length: {len(jpeg)}\r\n\r\n"
|
||||||
|
).encode("ascii")
|
||||||
|
self.wfile.write(header)
|
||||||
|
self.wfile.write(jpeg)
|
||||||
|
self.wfile.write(b"\r\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class ReusableThreadingHTTPServer(ThreadingHTTPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
def start_http(host: str, port: int) -> ThreadingHTTPServer:
|
||||||
|
server = ReusableThreadingHTTPServer((host, port), ImageRequestHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def import_message_class(topic_type: str) -> Any:
|
||||||
|
parts = topic_type.split("/")
|
||||||
|
if len(parts) != 3 or parts[1] != "msg":
|
||||||
|
raise ValueError(f"expected ROS2 message type like pkg/msg/Name, got {topic_type}")
|
||||||
|
module = importlib.import_module(f"{parts[0]}.msg")
|
||||||
|
return getattr(module, parts[2])
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown_rclpy_once(rclpy_module: Any) -> None:
|
||||||
|
if rclpy_module.ok():
|
||||||
|
rclpy_module.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def discover_topic_type(node: Any, topic: str, timeout: float) -> str:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
for name, types in node.get_topic_names_and_types():
|
||||||
|
if name == topic and types:
|
||||||
|
return types[0]
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise TimeoutError(f"topic {topic} not found within {timeout:.1f}s")
|
||||||
|
|
||||||
|
|
||||||
|
def spin_ros(
|
||||||
|
topic: str,
|
||||||
|
topic_type: str,
|
||||||
|
quality: int,
|
||||||
|
max_fps: float,
|
||||||
|
discovery_timeout: float,
|
||||||
|
force_compress: bool,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
import rclpy
|
||||||
|
from rclpy.executors import ExternalShutdownException
|
||||||
|
from rclpy.node import Node
|
||||||
|
except Exception as exc:
|
||||||
|
update_status(False, f"ROS imports failed: {exc}", topic=topic, topic_type=topic_type)
|
||||||
|
return
|
||||||
|
|
||||||
|
class BridgeNode(Node):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("image_web_bridge")
|
||||||
|
actual_type = topic_type
|
||||||
|
if actual_type == "auto":
|
||||||
|
actual_type = discover_topic_type(self, topic, discovery_timeout)
|
||||||
|
msg_class = import_message_class(actual_type)
|
||||||
|
self.actual_type = actual_type
|
||||||
|
self.last_emit = 0.0
|
||||||
|
self.fps = FrameRateTracker()
|
||||||
|
self.create_subscription(msg_class, topic, self.on_image, 10)
|
||||||
|
update_status(
|
||||||
|
False,
|
||||||
|
"subscribed, waiting for first frame",
|
||||||
|
topic=topic,
|
||||||
|
topic_type=actual_type,
|
||||||
|
compress=force_compress,
|
||||||
|
quality=quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_image(self, msg: Any) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
if max_fps > 0 and now - self.last_emit < 1.0 / max_fps:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
jpeg = message_to_jpeg(msg, self.actual_type, quality, force_compress)
|
||||||
|
except Exception as exc:
|
||||||
|
update_status(False, f"frame conversion failed: {exc}", topic=topic, topic_type=self.actual_type)
|
||||||
|
return
|
||||||
|
self.last_emit = now
|
||||||
|
publish_frame(jpeg, topic, self.actual_type, self.fps.record())
|
||||||
|
|
||||||
|
try:
|
||||||
|
rclpy.init()
|
||||||
|
node = BridgeNode()
|
||||||
|
except Exception as exc:
|
||||||
|
update_status(False, f"ROS setup failed: {exc}", topic=topic, topic_type=topic_type)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
except ExternalShutdownException:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
shutdown_rclpy_once(rclpy)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8080)
|
||||||
|
parser.add_argument("--topic", default="/image_mjpeg")
|
||||||
|
parser.add_argument("--topic-type", default="auto")
|
||||||
|
parser.add_argument("--quality", type=int, default=75)
|
||||||
|
parser.add_argument("--compress", action="store_true")
|
||||||
|
parser.add_argument("--max-fps", type=float, default=15.0)
|
||||||
|
parser.add_argument("--discovery-timeout", type=float, default=10.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
update_status(
|
||||||
|
False,
|
||||||
|
"HTTP started, waiting for ROS",
|
||||||
|
topic=args.topic,
|
||||||
|
topic_type=args.topic_type,
|
||||||
|
compress=args.compress,
|
||||||
|
quality=args.quality,
|
||||||
|
)
|
||||||
|
server = start_http(args.host, args.port)
|
||||||
|
print(f"image web bridge: http://{args.host}:{args.port}", flush=True)
|
||||||
|
print(f"topic: {args.topic} type: {args.topic_type}", flush=True)
|
||||||
|
|
||||||
|
stop = threading.Event()
|
||||||
|
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
||||||
|
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||||
|
|
||||||
|
ros_thread = threading.Thread(
|
||||||
|
target=spin_ros,
|
||||||
|
args=(
|
||||||
|
args.topic,
|
||||||
|
args.topic_type,
|
||||||
|
args.quality,
|
||||||
|
args.max_fps,
|
||||||
|
args.discovery_timeout,
|
||||||
|
args.compress,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
ros_thread.start()
|
||||||
|
|
||||||
|
while not stop.is_set():
|
||||||
|
time.sleep(0.2)
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
73
src/car_usb_cam/image_web_bridge_README.md
Normal file
73
src/car_usb_cam/image_web_bridge_README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Scanner Debug Web
|
||||||
|
|
||||||
|
Minimal local web debug view for `obstacle_scanner`.
|
||||||
|
|
||||||
|
Run from WSL after the machine can see the ROS2 graph:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/d/Programme/agent/projects/yiliao/scanner_debug_web
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8767
|
||||||
|
```
|
||||||
|
|
||||||
|
Topics used:
|
||||||
|
|
||||||
|
- `/obstacles` (`obstacle_scanner/msg/ObstacleArray`)
|
||||||
|
- `/obstacle_scanner/debug_info` (`std_msgs/msg/String` JSON)
|
||||||
|
- `/scan` (`sensor_msgs/msg/LaserScan`)
|
||||||
|
|
||||||
|
If the `obstacle_scanner` message package is installed in a non-default workspace, set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export YILIAO_WS_SETUP=/path/to/yiliao_ws/install/setup.bash
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Image Web Bridge
|
||||||
|
|
||||||
|
`image_web_bridge.py` forwards ROS2 image topics to a browser MJPEG stream.
|
||||||
|
|
||||||
|
It supports the image publishing modes used by `car_usb_cam`:
|
||||||
|
|
||||||
|
- `/image` (`sensor_msgs/msg/Image`) from `hobot_usb_cam` when `usb_zero_copy:=False`
|
||||||
|
- `/image_mjpeg` or `/image_jpeg` (`sensor_msgs/msg/CompressedImage`) from `hobot_codec`
|
||||||
|
- `/hbmem_img` (`hbm_img_msgs/msg/HbmMsg1080P`, `HbmMsg540P`, or `HbmMsg480P`) from zero-copy camera paths
|
||||||
|
|
||||||
|
Run on RDKx5:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/sunrise/yiliao_ws/src/car_usb_cam
|
||||||
|
source /opt/ros/humble/setup.bash
|
||||||
|
source /opt/tros/humble/setup.bash
|
||||||
|
source /home/sunrise/yiliao_ws/install/setup.bash
|
||||||
|
python3 image_web_bridge.py --host 192.168.10.210 --port 8080 --topic /image_mjpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
Open from another device on the same network:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://192.168.10.210:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Use explicit topic types when the publisher is not running yet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 image_web_bridge.py --host 192.168.10.210 --port 8080 --topic /image --topic-type sensor_msgs/msg/Image
|
||||||
|
python3 image_web_bridge.py --host 192.168.10.210 --port 8080 --topic /image_mjpeg --topic-type sensor_msgs/msg/CompressedImage
|
||||||
|
python3 image_web_bridge.py --host 192.168.10.210 --port 8080 --topic /hbmem_img --topic-type hbm_img_msgs/msg/HbmMsg1080P
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, JPEG `CompressedImage` topics are passed through without re-encoding.
|
||||||
|
Add `--compress` to force every frame sent to the browser through JPEG encoding,
|
||||||
|
using `--quality` as the output quality:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 image_web_bridge.py --host 192.168.10.164 --port 8080 --topic /image_mjpeg --compress --quality 45
|
||||||
|
```
|
||||||
|
|
||||||
|
The page header shows the current output FPS and total forwarded frame count.
|
||||||
18
src/car_usb_cam/run_tf_viewer.sh
Executable file
18
src/car_usb_cam/run_tf_viewer.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
source /opt/ros/humble/setup.bash
|
||||||
|
|
||||||
|
if [ -f /opt/tros/humble/setup.bash ]; then
|
||||||
|
source /opt/tros/humble/setup.bash
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${YILIAO_WS_SETUP:-}" ]; then
|
||||||
|
source "$YILIAO_WS_SETUP"
|
||||||
|
elif [ -f "$HOME/yiliao_ws/install/setup.bash" ]; then
|
||||||
|
source "$HOME/yiliao_ws/install/setup.bash"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
python3 tf_web_bridge.py "$@"
|
||||||
|
|
||||||
488
src/car_usb_cam/tf_viewer.html
Normal file
488
src/car_usb_cam/tf_viewer.html
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>TF Viewer</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #101216;
|
||||||
|
--panel: #181b21;
|
||||||
|
--soft: #20242c;
|
||||||
|
--canvas: #0d0f13;
|
||||||
|
--line: #303642;
|
||||||
|
--grid: #242933;
|
||||||
|
--text: #eef2f7;
|
||||||
|
--muted: #9aa4b2;
|
||||||
|
--live: #2e9cff;
|
||||||
|
--static: #2fbf71;
|
||||||
|
--warn: #f2a23c;
|
||||||
|
--map: #6f7f95;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
.app {
|
||||||
|
max-width: 1360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
header, .toolbar, .legend, .row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
header { justify-content: space-between; align-items: end; }
|
||||||
|
h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||||
|
.muted, label, .status { color: var(--muted); }
|
||||||
|
.status { font-size: 13px; }
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
button, select, input {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--soft);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
padding: 6px 9px;
|
||||||
|
}
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button[aria-pressed="true"] { border-color: var(--live); color: var(--live); }
|
||||||
|
input[type="range"] { width: 130px; padding: 0; }
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background: var(--canvas);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.legend { margin-top: 10px; color: var(--muted); font-size: 13px; }
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--c);
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.stat strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.table-wrap { max-height: 420px; overflow: auto; border-top: 1px solid var(--line); }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 7px 4px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th { color: var(--muted); font-weight: 500; position: sticky; top: 0; background: var(--panel); }
|
||||||
|
code { color: var(--text); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
main { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>TF Viewer</h1>
|
||||||
|
<div class="muted">?????? /tf ??/tf_static?????? /map ???????????/div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="pause" type="button" aria-pressed="false">Pause</button>
|
||||||
|
<label>follow <select id="focus"></select></label>
|
||||||
|
<label>scale <input id="scale" type="range" min="40" max="260" value="120"> <span id="scaleText">120</span> px/m</label>
|
||||||
|
<span id="status" class="status">connecting...</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="panel">
|
||||||
|
<svg id="mapView" viewBox="0 0 940 620" role="img" aria-label="TF map view">
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid" width="60" height="60" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 60 0 L 0 0 0 60" fill="none" stroke="var(--grid)" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
<marker id="arrow-live" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||||
|
<path d="M 0 0 L 8 4 L 0 8 z" fill="var(--live)"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-static" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||||
|
<path d="M 0 0 L 8 4 L 0 8 z" fill="var(--static)"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect width="940" height="620" fill="url(#grid)"/>
|
||||||
|
<g id="mapLayer"></g>
|
||||||
|
<g id="edgeLayer"></g>
|
||||||
|
<g id="frameLayer"></g>
|
||||||
|
</svg>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="dot" style="--c:var(--live)"></i>dynamic TF</span>
|
||||||
|
<span><i class="dot" style="--c:var(--static)"></i>static TF</span>
|
||||||
|
<span><i class="dot" style="--c:var(--map)"></i>map image</span>
|
||||||
|
<span id="mapInfo">map: none</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="panel">
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><span class="muted">frames</span><strong id="frameCount">0</strong></div>
|
||||||
|
<div class="stat"><span class="muted">transforms</span><strong id="edgeCount">0</strong></div>
|
||||||
|
<div class="stat"><span class="muted">roots</span><strong id="rootCount">0</strong></div>
|
||||||
|
<div class="stat"><span class="muted">last update</span><strong id="lastUpdate">-</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>parent</th><th>child</th><th>xyz</th><th>type</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tfRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const state = {
|
||||||
|
edges: new Map(),
|
||||||
|
map: null,
|
||||||
|
mapAsset: null,
|
||||||
|
bridgeStatus: null,
|
||||||
|
lastEvent: 0,
|
||||||
|
};
|
||||||
|
const mapImage = new Image();
|
||||||
|
let paused = false;
|
||||||
|
let mapImageLoaded = false;
|
||||||
|
|
||||||
|
const svg = document.getElementById('mapView');
|
||||||
|
const mapLayer = document.getElementById('mapLayer');
|
||||||
|
const edgeLayer = document.getElementById('edgeLayer');
|
||||||
|
const frameLayer = document.getElementById('frameLayer');
|
||||||
|
const focusSelect = document.getElementById('focus');
|
||||||
|
const scaleInput = document.getElementById('scale');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
function setText(id, value) {
|
||||||
|
document.getElementById(id).textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeKey(edge) {
|
||||||
|
return `${edge.parent}->${edge.child}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiveTf(payload, kind) {
|
||||||
|
if (!payload || !Array.isArray(payload.transforms)) return;
|
||||||
|
payload.transforms.forEach(edge => {
|
||||||
|
state.edges.set(edgeKey(edge), {...edge, kind, receivedAt: Date.now()});
|
||||||
|
});
|
||||||
|
state.lastEvent = Date.now();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function originFromMap() {
|
||||||
|
if (state.map && state.map.info && state.map.info.origin) {
|
||||||
|
const pos = state.map.info.origin.position;
|
||||||
|
return [Number(pos.x || 0), Number(pos.y || 0), 0];
|
||||||
|
}
|
||||||
|
if (state.mapAsset && Array.isArray(state.mapAsset.origin)) {
|
||||||
|
return state.mapAsset.origin.map(Number);
|
||||||
|
}
|
||||||
|
return [0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolutionFromMap() {
|
||||||
|
if (state.map && state.map.info && Number(state.map.info.resolution) > 0) {
|
||||||
|
return Number(state.map.info.resolution);
|
||||||
|
}
|
||||||
|
if (state.mapAsset && Number(state.mapAsset.resolution) > 0) {
|
||||||
|
return Number(state.mapAsset.resolution);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageSizeFromMap() {
|
||||||
|
if (state.map && state.map.info && state.map.info.width && state.map.info.height) {
|
||||||
|
return [Number(state.map.info.width), Number(state.map.info.height)];
|
||||||
|
}
|
||||||
|
if (state.mapAsset && state.mapAsset.width && state.mapAsset.height) {
|
||||||
|
return [Number(state.mapAsset.width), Number(state.mapAsset.height)];
|
||||||
|
}
|
||||||
|
if (mapImageLoaded) return [mapImage.naturalWidth, mapImage.naturalHeight];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldToSvg(x, y, center) {
|
||||||
|
const scale = Number(scaleInput.value);
|
||||||
|
return {
|
||||||
|
x: 470 + (x - center.x) * scale,
|
||||||
|
y: 310 - (y - center.y) * scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function yawFromQuat(q) {
|
||||||
|
const x = Number(q.x || 0), y = Number(q.y || 0), z = Number(q.z || 0), w = Number(q.w || 1);
|
||||||
|
return Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z));
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePositions() {
|
||||||
|
const edges = [...state.edges.values()];
|
||||||
|
const byChild = new Map();
|
||||||
|
const children = new Set();
|
||||||
|
const frames = new Set();
|
||||||
|
edges.forEach(edge => {
|
||||||
|
byChild.set(edge.child, edge);
|
||||||
|
children.add(edge.child);
|
||||||
|
frames.add(edge.parent);
|
||||||
|
frames.add(edge.child);
|
||||||
|
});
|
||||||
|
const roots = [...frames].filter(frame => !children.has(frame)).sort();
|
||||||
|
const positions = new Map();
|
||||||
|
roots.forEach(root => positions.set(root, {x: 0, y: 0, yaw: 0, depth: 0}));
|
||||||
|
if (frames.has('map') && !positions.has('map')) positions.set('map', {x: 0, y: 0, yaw: 0, depth: 0});
|
||||||
|
if (frames.has('odom') && !positions.has('odom')) positions.set('odom', {x: 0, y: 0, yaw: 0, depth: 0});
|
||||||
|
|
||||||
|
let changed = true;
|
||||||
|
for (let pass = 0; pass < frames.size + 2 && changed; pass++) {
|
||||||
|
changed = false;
|
||||||
|
edges.forEach(edge => {
|
||||||
|
const parent = positions.get(edge.parent);
|
||||||
|
if (!parent || positions.has(edge.child)) return;
|
||||||
|
const t = edge.translation || {};
|
||||||
|
const yaw = parent.yaw + yawFromQuat(edge.rotation || {});
|
||||||
|
const cos = Math.cos(parent.yaw);
|
||||||
|
const sin = Math.sin(parent.yaw);
|
||||||
|
positions.set(edge.child, {
|
||||||
|
x: parent.x + Number(t.x || 0) * cos - Number(t.y || 0) * sin,
|
||||||
|
y: parent.y + Number(t.x || 0) * sin + Number(t.y || 0) * cos,
|
||||||
|
yaw,
|
||||||
|
depth: parent.depth + 1,
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let index = 0;
|
||||||
|
frames.forEach(frame => {
|
||||||
|
if (positions.has(frame)) return;
|
||||||
|
positions.set(frame, {x: -2 + (index % 5), y: 2 + Math.floor(index / 5) * 0.5, yaw: 0, depth: 0});
|
||||||
|
index += 1;
|
||||||
|
});
|
||||||
|
return {positions, roots};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedCenter(positions) {
|
||||||
|
const selected = focusSelect.value;
|
||||||
|
if (selected && positions.has(selected)) return positions.get(selected);
|
||||||
|
if (positions.has('base_footprint')) return positions.get('base_footprint');
|
||||||
|
if (positions.has('base_link')) return positions.get('base_link');
|
||||||
|
if (positions.has('odom')) return positions.get('odom');
|
||||||
|
return {x: 0, y: 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMap(center) {
|
||||||
|
mapLayer.innerHTML = '';
|
||||||
|
const size = imageSizeFromMap();
|
||||||
|
const resolution = resolutionFromMap();
|
||||||
|
const url = state.mapAsset && state.mapAsset.url;
|
||||||
|
if (!url || !size || !resolution) {
|
||||||
|
setText('mapInfo', state.map ? 'map: /map metadata only' : 'map: none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [width, height] = size;
|
||||||
|
const [originX, originY] = originFromMap();
|
||||||
|
const topLeft = worldToSvg(originX, originY + height * resolution, center);
|
||||||
|
const pxWidth = width * resolution * Number(scaleInput.value);
|
||||||
|
const pxHeight = height * resolution * Number(scaleInput.value);
|
||||||
|
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
|
||||||
|
image.setAttribute('href', url);
|
||||||
|
image.setAttribute('x', topLeft.x);
|
||||||
|
image.setAttribute('y', topLeft.y);
|
||||||
|
image.setAttribute('width', pxWidth);
|
||||||
|
image.setAttribute('height', pxHeight);
|
||||||
|
image.setAttribute('opacity', '0.44');
|
||||||
|
image.setAttribute('preserveAspectRatio', 'none');
|
||||||
|
mapLayer.appendChild(image);
|
||||||
|
|
||||||
|
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||||
|
rect.setAttribute('x', topLeft.x);
|
||||||
|
rect.setAttribute('y', topLeft.y);
|
||||||
|
rect.setAttribute('width', pxWidth);
|
||||||
|
rect.setAttribute('height', pxHeight);
|
||||||
|
rect.setAttribute('fill', 'none');
|
||||||
|
rect.setAttribute('stroke', 'var(--map)');
|
||||||
|
rect.setAttribute('stroke-width', '2');
|
||||||
|
mapLayer.appendChild(rect);
|
||||||
|
setText('mapInfo', `map: ${width}x${height}, res ${resolution.toFixed(4)} m/px, origin ${originX.toFixed(2)}, ${originY.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGraph(positions, center) {
|
||||||
|
edgeLayer.innerHTML = '';
|
||||||
|
frameLayer.innerHTML = '';
|
||||||
|
[...state.edges.values()].forEach(edge => {
|
||||||
|
const a = positions.get(edge.parent);
|
||||||
|
const b = positions.get(edge.child);
|
||||||
|
if (!a || !b) return;
|
||||||
|
const p1 = worldToSvg(a.x, a.y, center);
|
||||||
|
const p2 = worldToSvg(b.x, b.y, center);
|
||||||
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
|
line.setAttribute('x1', p1.x);
|
||||||
|
line.setAttribute('y1', p1.y);
|
||||||
|
line.setAttribute('x2', p2.x);
|
||||||
|
line.setAttribute('y2', p2.y);
|
||||||
|
line.setAttribute('stroke', edge.kind === 'static' ? 'var(--static)' : 'var(--live)');
|
||||||
|
line.setAttribute('stroke-width', edge.kind === 'static' ? '2' : '2.5');
|
||||||
|
line.setAttribute('marker-end', edge.kind === 'static' ? 'url(#arrow-static)' : 'url(#arrow-live)');
|
||||||
|
if (edge.kind === 'static') line.setAttribute('stroke-dasharray', '5 5');
|
||||||
|
edgeLayer.appendChild(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
[...positions.entries()].forEach(([frame, pos]) => {
|
||||||
|
const p = worldToSvg(pos.x, pos.y, center);
|
||||||
|
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
|
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
|
circle.setAttribute('cx', p.x);
|
||||||
|
circle.setAttribute('cy', p.y);
|
||||||
|
circle.setAttribute('r', frame === focusSelect.value ? '7' : '5');
|
||||||
|
circle.setAttribute('fill', frame === 'map' ? 'var(--map)' : 'var(--text)');
|
||||||
|
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
text.setAttribute('x', p.x + 8);
|
||||||
|
text.setAttribute('y', p.y - 8);
|
||||||
|
text.setAttribute('fill', 'var(--text)');
|
||||||
|
text.setAttribute('font-size', '13');
|
||||||
|
text.textContent = frame;
|
||||||
|
group.appendChild(circle);
|
||||||
|
group.appendChild(text);
|
||||||
|
frameLayer.appendChild(group);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows() {
|
||||||
|
const rows = [...state.edges.values()]
|
||||||
|
.sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)))
|
||||||
|
.map(edge => {
|
||||||
|
const t = edge.translation || {};
|
||||||
|
return `<tr><td><code>${escapeHtml(edge.parent)}</code></td><td><code>${escapeHtml(edge.child)}</code></td><td>${Number(t.x || 0).toFixed(3)}, ${Number(t.y || 0).toFixed(3)}, ${Number(t.z || 0).toFixed(3)}</td><td>${edge.kind}</td></tr>`;
|
||||||
|
});
|
||||||
|
document.getElementById('tfRows').innerHTML = rows.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFocusOptions(frames) {
|
||||||
|
const current = focusSelect.value;
|
||||||
|
const options = [''].concat([...frames].sort());
|
||||||
|
focusSelect.innerHTML = options.map(frame => `<option value="${escapeHtml(frame)}">${frame ? escapeHtml(frame) : 'auto'}</option>`).join('');
|
||||||
|
if (options.includes(current)) focusSelect.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const {positions, roots} = computePositions();
|
||||||
|
refreshFocusOptions(new Set(positions.keys()));
|
||||||
|
const center = selectedCenter(positions);
|
||||||
|
renderMap(center);
|
||||||
|
renderGraph(positions, center);
|
||||||
|
renderRows();
|
||||||
|
setText('frameCount', positions.size);
|
||||||
|
setText('edgeCount', state.edges.size);
|
||||||
|
setText('rootCount', roots.length);
|
||||||
|
setText('lastUpdate', state.lastEvent ? `${((Date.now() - state.lastEvent) / 1000).toFixed(1)}s` : '-');
|
||||||
|
const status = state.bridgeStatus && state.bridgeStatus.message ? state.bridgeStatus.message : 'waiting';
|
||||||
|
statusEl.textContent = `${status}${paused ? ', paused' : ''}`;
|
||||||
|
document.getElementById('scaleText').textContent = scaleInput.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pause').addEventListener('click', event => {
|
||||||
|
paused = !paused;
|
||||||
|
event.currentTarget.setAttribute('aria-pressed', paused ? 'true' : 'false');
|
||||||
|
event.currentTarget.textContent = paused ? 'Resume' : 'Pause';
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
focusSelect.addEventListener('change', render);
|
||||||
|
scaleInput.addEventListener('input', render);
|
||||||
|
|
||||||
|
function receiveMessage(message) {
|
||||||
|
if (paused && message.type !== 'bridge_status') return;
|
||||||
|
if (message.type === 'snapshot') {
|
||||||
|
const data = message.data || {};
|
||||||
|
state.bridgeStatus = data.bridge_status;
|
||||||
|
state.map = data.map;
|
||||||
|
state.mapAsset = data.map_asset;
|
||||||
|
if (data.tf_static) receiveTf(data.tf_static, 'static');
|
||||||
|
if (data.tf) receiveTf(data.tf, 'dynamic');
|
||||||
|
} else if (message.type === 'tf') {
|
||||||
|
receiveTf(message.data, 'dynamic');
|
||||||
|
} else if (message.type === 'tf_static') {
|
||||||
|
receiveTf(message.data, 'static');
|
||||||
|
} else if (message.type === 'map') {
|
||||||
|
state.map = message.data;
|
||||||
|
state.lastEvent = Date.now();
|
||||||
|
} else if (message.type === 'map_asset') {
|
||||||
|
state.mapAsset = message.data;
|
||||||
|
} else if (message.type === 'bridge_status') {
|
||||||
|
state.bridgeStatus = message.data;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/latest', {cache: 'no-store'})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => receiveMessage({type: 'snapshot', data}))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
const events = new EventSource('/events');
|
||||||
|
events.onopen = () => { statusEl.textContent = 'connected'; };
|
||||||
|
events.onerror = () => { statusEl.textContent = 'disconnected'; };
|
||||||
|
events.onmessage = event => receiveMessage(JSON.parse(event.data));
|
||||||
|
|
||||||
|
fetch('/latest', {cache: 'no-store'}).then(res => res.json()).then(data => {
|
||||||
|
if (data.map_asset && data.map_asset.url) {
|
||||||
|
state.mapAsset = data.map_asset;
|
||||||
|
mapImage.onload = () => { mapImageLoaded = true; render(); };
|
||||||
|
mapImage.src = data.map_asset.url;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}).catch(render);
|
||||||
|
window.setInterval(render, 1000);
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
385
src/car_usb_cam/tf_web_bridge.py
Normal file
385
src/car_usb_cam/tf_web_bridge.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ROS2 TF to browser bridge with optional static map image overlay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import struct
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import queue
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
CLIENTS: list[queue.Queue[str]] = []
|
||||||
|
CLIENTS_LOCK = threading.Lock()
|
||||||
|
MAP_ASSET: "MapAsset | None" = None
|
||||||
|
LATEST: dict[str, Any] = {
|
||||||
|
"tf": None,
|
||||||
|
"tf_static": None,
|
||||||
|
"map": None,
|
||||||
|
"bridge_status": {"ok": False, "message": "ROS thread not started"},
|
||||||
|
"connected_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MapAsset:
|
||||||
|
path: Path
|
||||||
|
content_type: str
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def request_path(raw_path: str) -> str:
|
||||||
|
return urlsplit(raw_path).path
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_to_float(stamp: Any) -> float | None:
|
||||||
|
if stamp is None:
|
||||||
|
return None
|
||||||
|
return float(getattr(stamp, "sec", 0)) + float(getattr(stamp, "nanosec", 0)) / 1_000_000_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def vector_to_dict(value: Any) -> dict[str, float]:
|
||||||
|
return {
|
||||||
|
"x": float(getattr(value, "x", 0.0)),
|
||||||
|
"y": float(getattr(value, "y", 0.0)),
|
||||||
|
"z": float(getattr(value, "z", 0.0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def quaternion_to_dict(value: Any) -> dict[str, float]:
|
||||||
|
return {
|
||||||
|
"x": float(getattr(value, "x", 0.0)),
|
||||||
|
"y": float(getattr(value, "y", 0.0)),
|
||||||
|
"z": float(getattr(value, "z", 0.0)),
|
||||||
|
"w": float(getattr(value, "w", 1.0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tf_message_to_dict(msg: Any) -> dict[str, Any]:
|
||||||
|
transforms = []
|
||||||
|
for transform in msg.transforms:
|
||||||
|
header = transform.header
|
||||||
|
transforms.append(
|
||||||
|
{
|
||||||
|
"parent": str(header.frame_id),
|
||||||
|
"child": str(transform.child_frame_id),
|
||||||
|
"stamp": stamp_to_float(getattr(header, "stamp", None)),
|
||||||
|
"translation": vector_to_dict(transform.transform.translation),
|
||||||
|
"rotation": quaternion_to_dict(transform.transform.rotation),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"count": len(transforms), "transforms": transforms}
|
||||||
|
|
||||||
|
|
||||||
|
def map_message_to_dict(msg: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"header": {
|
||||||
|
"frame_id": msg.header.frame_id,
|
||||||
|
"stamp": stamp_to_float(msg.header.stamp),
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"width": int(msg.info.width),
|
||||||
|
"height": int(msg.info.height),
|
||||||
|
"resolution": float(msg.info.resolution),
|
||||||
|
"origin": {
|
||||||
|
"position": vector_to_dict(msg.info.origin.position),
|
||||||
|
"orientation": quaternion_to_dict(msg.info.origin.orientation),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_yaml_scalar(value: str) -> Any:
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if value.startswith("[") and value.endswith("]"):
|
||||||
|
return [parse_yaml_scalar(part) for part in value[1:-1].split(",")]
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def load_simple_yaml(path: Path) -> dict[str, Any]:
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.split("#", 1)[0].strip()
|
||||||
|
if not line or ":" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
data[key.strip()] = parse_yaml_scalar(value)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def read_png_size(path: Path) -> tuple[int, int] | None:
|
||||||
|
data = path.read_bytes()[:24]
|
||||||
|
if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_jpeg_size(path: Path) -> tuple[int, int] | None:
|
||||||
|
data = path.read_bytes()
|
||||||
|
if not data.startswith(b"\xff\xd8"):
|
||||||
|
return None
|
||||||
|
index = 2
|
||||||
|
while index + 9 < len(data):
|
||||||
|
if data[index] != 0xFF:
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
marker = data[index + 1]
|
||||||
|
index += 2
|
||||||
|
if marker in (0xD8, 0xD9):
|
||||||
|
continue
|
||||||
|
if index + 2 > len(data):
|
||||||
|
return None
|
||||||
|
length = struct.unpack(">H", data[index : index + 2])[0]
|
||||||
|
if marker in range(0xC0, 0xC4) and index + 7 < len(data):
|
||||||
|
height = struct.unpack(">H", data[index + 3 : index + 5])[0]
|
||||||
|
width = struct.unpack(">H", data[index + 5 : index + 7])[0]
|
||||||
|
return width, height
|
||||||
|
index += length
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_image_size(path: Path) -> tuple[int, int] | None:
|
||||||
|
return read_png_size(path) or read_jpeg_size(path)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_content_type(path: Path) -> str:
|
||||||
|
header = path.read_bytes()[:12]
|
||||||
|
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
return "image/png"
|
||||||
|
if header.startswith(b"\xff\xd8"):
|
||||||
|
return "image/jpeg"
|
||||||
|
return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
def load_map_asset(
|
||||||
|
image_path: str | None,
|
||||||
|
yaml_path: str | None = None,
|
||||||
|
map_width_meters: float = 5.0,
|
||||||
|
) -> MapAsset | None:
|
||||||
|
if not image_path:
|
||||||
|
return None
|
||||||
|
path = Path(image_path).expanduser().resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(f"map image not found: {path}")
|
||||||
|
content_type = detect_content_type(path)
|
||||||
|
metadata: dict[str, Any] = {"image": path.name, "placement": "publish_map_windows"}
|
||||||
|
image_size = read_image_size(path)
|
||||||
|
if image_size is not None:
|
||||||
|
width, height = image_size
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"resolution": float(map_width_meters) / float(width),
|
||||||
|
"origin": [0.0, 0.0, 0.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata_path = Path(yaml_path).expanduser().resolve() if yaml_path else path.with_suffix(".yaml")
|
||||||
|
if metadata_path.is_file():
|
||||||
|
metadata.update(load_simple_yaml(metadata_path))
|
||||||
|
return MapAsset(path=path, content_type=content_type, metadata=metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_event(event_type: str, data: Any) -> None:
|
||||||
|
LATEST[event_type] = data
|
||||||
|
payload = json.dumps(
|
||||||
|
{"type": event_type, "data": data, "time": time.time()},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
with CLIENTS_LOCK:
|
||||||
|
clients = list(CLIENTS)
|
||||||
|
for client in clients:
|
||||||
|
client.put(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class TfRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "TfWebBridge/1.0"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: Any) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
path = request_path(self.path)
|
||||||
|
if path in ("/", "/index.html"):
|
||||||
|
self.serve_file(ROOT / "tf_viewer.html", "text/html; charset=utf-8")
|
||||||
|
return
|
||||||
|
if path == "/events":
|
||||||
|
self.serve_events()
|
||||||
|
return
|
||||||
|
if path == "/latest":
|
||||||
|
self.serve_json(LATEST)
|
||||||
|
return
|
||||||
|
if path == "/map-image" and MAP_ASSET is not None:
|
||||||
|
self.serve_file(MAP_ASSET.path, MAP_ASSET.content_type)
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def serve_file(self, path: Path, content_type: str) -> None:
|
||||||
|
data = path.read_bytes()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def serve_json(self, data: Any) -> None:
|
||||||
|
body = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def serve_events(self) -> None:
|
||||||
|
client: queue.Queue[str] = queue.Queue()
|
||||||
|
with CLIENTS_LOCK:
|
||||||
|
CLIENTS.append(client)
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Connection", "keep-alive")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.put(json.dumps({"type": "snapshot", "data": LATEST, "time": time.time()}))
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
payload = client.get(timeout=10.0)
|
||||||
|
self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
|
||||||
|
except queue.Empty:
|
||||||
|
self.wfile.write(b": keepalive\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
with CLIENTS_LOCK:
|
||||||
|
if client in CLIENTS:
|
||||||
|
CLIENTS.remove(client)
|
||||||
|
|
||||||
|
|
||||||
|
class ReusableThreadingHTTPServer(ThreadingHTTPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
def start_http(host: str, port: int) -> ThreadingHTTPServer:
|
||||||
|
server = ReusableThreadingHTTPServer((host, port), TfRequestHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def spin_ros(tf_topic: str, tf_static_topic: str, map_topic: str | None) -> None:
|
||||||
|
try:
|
||||||
|
import rclpy
|
||||||
|
from nav_msgs.msg import OccupancyGrid
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy
|
||||||
|
from tf2_msgs.msg import TFMessage
|
||||||
|
except Exception as exc:
|
||||||
|
message = f"ROS imports failed: {exc}"
|
||||||
|
print(message, flush=True)
|
||||||
|
publish_event("bridge_status", {"ok": False, "message": message})
|
||||||
|
return
|
||||||
|
|
||||||
|
class BridgeNode(Node):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("tf_web_bridge")
|
||||||
|
tf_qos = QoSProfile(depth=100)
|
||||||
|
tf_qos.reliability = ReliabilityPolicy.RELIABLE
|
||||||
|
static_qos = QoSProfile(depth=1)
|
||||||
|
static_qos.reliability = ReliabilityPolicy.RELIABLE
|
||||||
|
static_qos.durability = DurabilityPolicy.TRANSIENT_LOCAL
|
||||||
|
self.create_subscription(TFMessage, tf_topic, self.on_tf, tf_qos)
|
||||||
|
self.create_subscription(TFMessage, tf_static_topic, self.on_tf_static, static_qos)
|
||||||
|
if map_topic:
|
||||||
|
self.create_subscription(OccupancyGrid, map_topic, self.on_map, static_qos)
|
||||||
|
|
||||||
|
def on_tf(self, msg: Any) -> None:
|
||||||
|
publish_event("tf", tf_message_to_dict(msg))
|
||||||
|
|
||||||
|
def on_tf_static(self, msg: Any) -> None:
|
||||||
|
publish_event("tf_static", tf_message_to_dict(msg))
|
||||||
|
|
||||||
|
def on_map(self, msg: Any) -> None:
|
||||||
|
publish_event("map", map_message_to_dict(msg))
|
||||||
|
|
||||||
|
rclpy.init()
|
||||||
|
node = BridgeNode()
|
||||||
|
publish_event("bridge_status", {"ok": True, "message": "subscribed"})
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def bind_help() -> str:
|
||||||
|
return "bind address, default 127.0.0.1; use 192.168.10.164 to expose on that interface"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help=bind_help())
|
||||||
|
parser.add_argument("--port", type=int, default=8770)
|
||||||
|
parser.add_argument("--tf-topic", default="/tf")
|
||||||
|
parser.add_argument("--tf-static-topic", default="/tf_static")
|
||||||
|
parser.add_argument("--map-topic", default="/map")
|
||||||
|
parser.add_argument("--no-map-topic", action="store_true")
|
||||||
|
parser.add_argument("--map-image", default=None)
|
||||||
|
parser.add_argument("--map-yaml", default=None)
|
||||||
|
parser.add_argument("--map-width-meters", type=float, default=5.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
global MAP_ASSET
|
||||||
|
MAP_ASSET = load_map_asset(args.map_image, args.map_yaml, args.map_width_meters)
|
||||||
|
if MAP_ASSET:
|
||||||
|
LATEST["map_asset"] = {**MAP_ASSET.metadata, "url": "/map-image"}
|
||||||
|
|
||||||
|
server = start_http(args.host, args.port)
|
||||||
|
print(f"tf web bridge: http://{args.host}:{args.port}", flush=True)
|
||||||
|
if MAP_ASSET:
|
||||||
|
print(f"map image: {MAP_ASSET.path}", flush=True)
|
||||||
|
if args.host == "127.0.0.1":
|
||||||
|
print("bind: local loopback only; add --host 192.168.10.164 to expose that interface", flush=True)
|
||||||
|
|
||||||
|
stop = threading.Event()
|
||||||
|
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
||||||
|
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||||
|
|
||||||
|
map_topic = None if args.no_map_topic else args.map_topic
|
||||||
|
ros_thread = threading.Thread(
|
||||||
|
target=spin_ros,
|
||||||
|
args=(args.tf_topic, args.tf_static_topic, map_topic),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
ros_thread.start()
|
||||||
|
|
||||||
|
while not stop.is_set():
|
||||||
|
time.sleep(0.2)
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
7
调试记录.md
7
调试记录.md
@@ -180,3 +180,10 @@ Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
|
|||||||
|
|
||||||
## 6/20 调试记录
|
## 6/20 调试记录
|
||||||
所有工具/辅助类脚本全部放在tools文件夹下
|
所有工具/辅助类脚本全部放在tools文件夹下
|
||||||
|
|
||||||
|
## 7/20 调试记录
|
||||||
|
启动qr_detect的方式:
|
||||||
|
```bash
|
||||||
|
ros2 launch car_usb_cam hobot_usb_cam.launch.py # 终端1
|
||||||
|
ros2 launch qr_detection qr_detect.launch.py # 终端2
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user