Lidar_Muxa/tools/flyguard_ros2_node.py
Zhirik1337 200cb78348 ROS-узел, устойчивость к битому CDR, гейт регрессии полигона
Узел tools/flyguard_ros2_node.py (п. 16.7: отдавал массив вместо PointCloud2,
публиковал треки без решения) переписан: разбор через новый
flyguard.cdr.from_ros_message, публикация через flyguard.export.export_frame,
не падает от битого кадра, по умолчанию подключает artifacts/*.npz.

flyguard/bag.py: битый CDR-пакет в бэге пропускается с логом вместо обрыва
всего чтения (Bag.n_frames_failed). tools/compare_benchmark.py: флаг
--fail-on-net-down для CI-гейта регрессии по методологии парных переворотов
(EXPERIMENTS п. 16.1). Добавлен pyproject.toml (ruff, не прогнан — в
окружении нет ruff/pip). Тесты 46/46 (tests/test_pipeline.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 21:18:14 +03:00

211 lines
9.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
ROS 2 узел для биоинспирированного пайплайна FlyGuard (ЛЦТ-2026 Кейс 05).
Связывает входные данные 3D-лидара с ядром FlyGuard и публикует детекции/маркеры.
Топики:
вход <lidar_topic> sensor_msgs/PointCloud2 (Hesai Pandar128E3X)
выход flyguard/threat_level std_msgs/String CLEAR | WARNING | EMERGENCY
flyguard/status std_msgs/String JSON: дальность, TTC, скорость, рамки
flyguard/bounding_boxes vision_msgs/Detection3DArray
flyguard/markers visualization_msgs/MarkerArray
"""
import json
import math
import sys
import time
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent.parent
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Point
from sensor_msgs.msg import PointCloud2
from std_msgs.msg import String
from vision_msgs.msg import BoundingBox3D, Detection3D, Detection3DArray, ObjectHypothesisWithPose
from visualization_msgs.msg import Marker, MarkerArray
from flyguard.cdr import from_ros_message
from flyguard.export import ThreatLevel, export_frame
from flyguard.mbon_readout import MbonReadout
from flyguard.mushroom_body import MushroomBody
from flyguard.pipeline import FlyGuard, Params
from flyguard.track_readout import TrackReadout
ARTIFACTS = ROOT_DIR / "artifacts"
def _artifact(param: str, default_name: str) -> Path | None:
"""Пустой параметр — артефакт по умолчанию из artifacts/, если он есть; '-' — без него."""
if param == "-":
return None
path = Path(param) if param else ARTIFACTS / default_name
return path if path.exists() else None
class FlyGuardNode(Node):
def __init__(self):
super().__init__('flyguard_node')
self.declare_parameter('lidar_topic', '/lidar_points')
self.declare_parameter('frame_id', '')
self.declare_parameter('memory_path', '')
self.declare_parameter('readout_path', '')
self.declare_parameter('track_readout_path', '-')
self.declare_parameter('fov_deg', 30.0)
# на GPU ускоряется только ламина (EXPERIMENTS п. 7.4); 'auto' — если нужна видеокарта
self.declare_parameter('device', 'cpu')
gp = self.get_parameter
self.lidar_topic = gp('lidar_topic').value
self.frame_id = gp('frame_id').value
memory_path = _artifact(gp('memory_path').value, 'mushroom_body.npz')
readout_path = _artifact(gp('readout_path').value, 'mbon_readout.npz')
track_path = _artifact(gp('track_readout_path').value, 'track_readout.npz')
log = self.get_logger()
memory = MushroomBody.load(str(memory_path)) if memory_path else None
readout = MbonReadout.load(str(readout_path)) if readout_path else None
track_readout = TrackReadout.load(str(track_path)) if track_path else None
log.info(f"память тоннеля: {memory_path or 'нет'}; считывание MBON: "
f"{readout_path or 'нет'}; считывание треков: {track_path or 'нет'}")
if memory is None or readout is None:
log.warn("без памяти тоннеля и считывания MBON ложных тревог будет заметно больше")
params = Params(fov_deg=float(gp('fov_deg').value), device=gp('device').value)
self.fg = FlyGuard(params, memory=memory, readout=readout, track_readout=track_readout)
self.sub_cloud = self.create_subscription(
PointCloud2, self.lidar_topic, self.pointcloud_callback, 10)
self.pub_threat = self.create_publisher(String, 'flyguard/threat_level', 10)
self.pub_status = self.create_publisher(String, 'flyguard/status', 10)
self.pub_boxes = self.create_publisher(Detection3DArray, 'flyguard/bounding_boxes', 10)
self.pub_markers = self.create_publisher(MarkerArray, 'flyguard/markers', 10)
self.n_frames = 0
self.n_failed = 0
log.info(f"FlyGuard ROS 2 узел запущен, вход: {self.lidar_topic}, "
f"устройство: {self.fg.device}")
def pointcloud_callback(self, msg: PointCloud2):
t0 = time.perf_counter()
# Битый кадр не должен останавливать узел: следующий придёт через 100 мс.
try:
res = self.fg.process(from_ros_message(msg))
except Exception as e: # noqa: BLE001
self.n_failed += 1
self.get_logger().error(f"кадр пропущен ({self.n_failed} всего): "
f"{type(e).__name__}: {e}",
throttle_duration_sec=1.0)
return
if res is None: # идёт калибровка решётки по первым кадрам
return
self.n_frames += 1
frame_id = self.frame_id or msg.header.frame_id
out = export_frame(res.decision, self.fg.plane, self.fg.corridor, stamp=res.stamp)
self.pub_threat.publish(String(data=out.threat_level.name))
self.pub_status.publish(String(data=json.dumps(_finite(out.to_dict()),
ensure_ascii=False)))
self.publish_detections(out, msg.header.stamp, frame_id)
self.publish_markers(out, msg.header.stamp, frame_id)
elapsed_ms = (time.perf_counter() - t0) * 1e3
if out.threat_level != ThreatLevel.CLEAR:
self.get_logger().info(f"{out.threat_level.name}: {out.nearest_distance:.1f} м, "
f"TTC {out.ttc:.1f} с, кадр {elapsed_ms:.1f} мс",
throttle_duration_sec=0.5)
if elapsed_ms > 100.0:
self.get_logger().warn(f"кадр {elapsed_ms:.0f} мс — дольше периода лидара",
throttle_duration_sec=5.0)
def publish_detections(self, out, stamp, frame_id):
arr = Detection3DArray()
arr.header.stamp = stamp
arr.header.frame_id = frame_id
for b in out.boxes:
det = Detection3D()
det.header = arr.header
bbox = BoundingBox3D()
bbox.center.position.x = b.x
bbox.center.position.y = b.y
bbox.center.position.z = b.z
bbox.center.orientation.z = math.sin(b.yaw * 0.5)
bbox.center.orientation.w = math.cos(b.yaw * 0.5)
bbox.size.x, bbox.size.y, bbox.size.z = b.dx, b.dy, b.dz
det.bbox = bbox
hyp = ObjectHypothesisWithPose()
if hasattr(hyp, 'hypothesis'): # Humble и новее
hyp.hypothesis.class_id = b.threat_level.name
hyp.hypothesis.score = b.confidence
else: # Foxy
hyp.id = b.threat_level.name
hyp.score = b.confidence
hyp.pose.pose = bbox.center
det.results.append(hyp)
det.id = str(b.track_id)
arr.detections.append(det)
self.pub_boxes.publish(arr)
def publish_markers(self, out, stamp, frame_id):
arr = MarkerArray()
clear = Marker()
clear.header.frame_id = frame_id
clear.action = Marker.DELETEALL
arr.markers.append(clear)
for m in out.to_rviz_markers(frame_id):
arr.markers.append(_marker_from_dict(m, stamp))
self.pub_markers.publish(arr)
def _marker_from_dict(d: dict, stamp) -> Marker:
m = Marker()
m.header.stamp = stamp
m.header.frame_id = d["header"]["frame_id"]
m.ns, m.id, m.type, m.action = d["ns"], d["id"], d["type"], d["action"]
if "pose" in d:
p, q = d["pose"]["position"], d["pose"]["orientation"]
m.pose.position.x, m.pose.position.y, m.pose.position.z = p["x"], p["y"], p["z"]
m.pose.orientation.x, m.pose.orientation.y = q["x"], q["y"]
m.pose.orientation.z, m.pose.orientation.w = q["z"], q["w"]
else:
m.pose.orientation.w = 1.0
s = d["scale"]
m.scale.x, m.scale.y, m.scale.z = s.get("x", 0.0), s.get("y", 0.0), s.get("z", 0.0)
c = d["color"]
m.color.r, m.color.g, m.color.b, m.color.a = c["r"], c["g"], c["b"], c["a"]
m.points = [Point(x=p["x"], y=p["y"], z=p["z"]) for p in d.get("points", [])]
m.text = d.get("text", "")
return m
def _finite(v):
"""JSON без Infinity/NaN: бесконечный TTC у свободного пути уходит как null."""
if isinstance(v, float):
return v if math.isfinite(v) else None
if isinstance(v, dict):
return {k: _finite(x) for k, x in v.items()}
if isinstance(v, (list, tuple)):
return [_finite(x) for x in v]
return v
def main(args=None):
rclpy.init(args=args)
node = FlyGuardNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.try_shutdown()
if __name__ == '__main__':
main()