Skip to content

Commit 2be959c

Browse files
chrisdok43claude
andcommitted
feat(go2-mapper): working go2-slam re-integration + bag download (built & run on Woof)
go2-slam now actually builds and runs on a real Go2 (Woof, JP6.1): - Point-LIO source -> SMBU-PolarBear point_lio@1.0.0 (real ROS2 port; the hku-mars ROS2 branch never existed). Vendored a minimal livox_ros_driver2 msg package so it compiles; added glog/python-dev/rmw-cyclonedds-cpp deps. - cloud_shim.py: the Go2 cloud is x,y,z,intensity only (no ring/time), which no stock Point-LIO handler accepts. Shim repacks it into the Velodyne layout (time as a tiny ramp so VELO16 doesn't synthesize bogus spin-timing) on /point_lio/cloud; config runs lidar_type=2 against it. Byte-packing unit-tested. - console: /bags + /download-bag route (FileResponse, memory-safe) to pull the recorded rosbag off the device. VERIFIED on hardware: builds, IMU inits to 100%, cloud ingested (no "Error LiDAR Type"), viz_bridge accumulates + snapshots map.pcd, export + quality scoring run. NOT yet working: stable mapping — Point-LIO diverges on the Go2 LiDAR's known-noisy /utlidar/imu (pose blows up); needs IMU tuning or offline reprocessing from the bag. Foxglove bridge must use a free port (8765 taken by a separate go2-foxglove-viz app on the device). Bag-download route compiles but wasn't on-device verified (device went offline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 585c173 commit 2be959c

9 files changed

Lines changed: 231 additions & 22 deletions

File tree

python/go2-mapper/go2-console/app.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
KEYFRAME_DIR = os.path.join(CURRENT, "keyframes")
4949
RAW_PCD = os.path.join(CURRENT, "map.pcd")
5050
REFINED_PCD = os.path.join(CURRENT, "map_refined.pcd")
51+
BAG_DIR = os.environ.get("BAG_DIR", "/data/bags") # shared with go2-recorder
5152

5253
app = FastAPI()
5354

@@ -288,6 +289,35 @@ def download_file(session: str, filename: str):
288289
return FileResponse(path, filename=filename)
289290

290291

292+
# ------------------------------------------------------- rosbag downloads
293+
# The recorder writes bags to the shared /data volume; expose them so the
294+
# raw capture can be pulled to a laptop (bags aren't zipped — the .mcap is
295+
# streamed from disk via FileResponse so multi-GB bags don't buffer in RAM).
296+
@app.get("/bags")
297+
def list_bags():
298+
if not os.path.isdir(BAG_DIR):
299+
return {"bags": []}
300+
out = []
301+
for s in sorted(os.listdir(BAG_DIR), reverse=True):
302+
d = os.path.join(BAG_DIR, s)
303+
if os.path.isdir(d):
304+
files = [{"name": f, "bytes": os.path.getsize(os.path.join(d, f)),
305+
"url": f"/download-bag/{s}/{f}"}
306+
for f in sorted(os.listdir(d))]
307+
out.append({"session": s, "files": files,
308+
"bytes": sum(f["bytes"] for f in files)})
309+
return {"bags": out}
310+
311+
312+
@app.get("/download-bag/{session}/{filename}")
313+
def download_bag_file(session: str, filename: str):
314+
base = os.path.realpath(BAG_DIR)
315+
path = os.path.realpath(os.path.join(base, session, filename))
316+
if not path.startswith(base + os.sep) or not os.path.isfile(path):
317+
raise HTTPException(404, "no such bag file")
318+
return FileResponse(path, filename=filename)
319+
320+
291321
# ------------------------------------------------- no-go zone editor API
292322
import numpy as np
293323
from PIL import Image

python/go2-mapper/go2-slam/Dockerfile

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,43 @@
33
# Point-LIO is chosen over FAST-LIO2 because it tolerates the jerky sensor
44
# motion of legged robots better. Both are light: ~1 CPU core on Orin NX.
55
#
6-
# NOTE (the fiddly part): Unitree publishes the Mid-360 as a standard
7-
# sensor_msgs/PointCloud2 on /utlidar/cloud_deskewed, NOT the livox custom
8-
# msg. config/mid360_go2.yaml therefore uses the generic PointCloud2
9-
# preprocessing path. If your firmware's cloud lacks per-point timestamps,
10-
# deskewed input still works — Unitree already motion-corrected it.
6+
# Point-LIO source: the SMBU-PolarBear ROS2 port (ament, executable
7+
# `pointlio_mapping`) pinned to tag 1.0.0. The upstream hku-mars repo has no
8+
# ROS2 branch. It references livox_ros_driver2::msg::CustomMsg unconditionally,
9+
# so we vendor a minimal msg-only livox_ros_driver2 package to satisfy the
10+
# build (the Livox path is never used at runtime).
11+
#
12+
# Input path: the Go2's /utlidar/cloud_deskewed is x,y,z,intensity only (no
13+
# ring / per-point time), which none of Point-LIO's stock PointCloud2 handlers
14+
# accept. cloud_shim.py repacks it into the Velodyne layout on /point_lio/cloud
15+
# and the config runs lidar_type=2 (VELO16) against that.
1116

1217
FROM ros:humble-ros-base
1318

1419
ENV DEBIAN_FRONTEND=noninteractive
1520
RUN apt-get update && apt-get install -y --no-install-recommends \
1621
build-essential git cmake \
17-
libeigen3-dev libpcl-dev \
22+
libeigen3-dev libpcl-dev libgoogle-glog-dev \
1823
ros-humble-pcl-conversions ros-humble-pcl-ros \
1924
ros-humble-foxglove-bridge \
20-
python3-pip \
25+
ros-humble-rmw-cyclonedds-cpp \
26+
python3-pip python3-dev python3-numpy \
2127
&& rm -rf /var/lib/apt/lists/*
2228

2329
RUN pip3 install --no-cache-dir numpy
2430

25-
# Build Point-LIO (ROS2 branch)
26-
WORKDIR /ws/src
27-
RUN git clone --depth 1 --branch ROS2 https://github.com/hku-mars/Point-LIO.git
31+
# Vendored livox_ros_driver2 messages (build-time only) + the real ROS2
32+
# Point-LIO. colcon builds livox_ros_driver2 first (Point-LIO depends on it).
33+
COPY livox_ros_driver2 /ws/src/livox_ros_driver2
34+
RUN git clone --depth 1 --branch 1.0.0 \
35+
https://github.com/SMBU-PolarBear-Robotics-Team/point_lio.git /ws/src/point_lio
2836
WORKDIR /ws
2937
RUN . /opt/ros/humble/setup.sh && \
3038
colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=Release
3139

3240
COPY config/mid360_go2.yaml /ws/config/mid360_go2.yaml
3341
COPY cyclonedds.xml /opt/cyclonedds.xml
42+
COPY cloud_shim.py /opt/cloud_shim.py
3443
COPY viz_bridge.py /opt/viz_bridge.py
3544
COPY entry.sh /opt/entry.sh
3645
RUN chmod +x /opt/entry.sh
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env python3
2+
"""cloud_shim.py — make the Go2's deskewed cloud digestible by Point-LIO.
3+
4+
The Go2 publishes /utlidar/cloud_deskewed as a plain PointCloud2 with only
5+
x,y,z,intensity (FLOAT32) — verified on hardware: no `ring`, no per-point
6+
`time`. Point-LIO's stock PointCloud2 handlers (Ouster/Velodyne/Hesai) all
7+
require ring+time and there is no generic-xyz path, so we repack each scan into
8+
the exact layout Point-LIO's VELO16 handler consumes — velodyne_ros::Point:
9+
x,y,z,intensity,time,ring — with time=0 and ring=0.
10+
11+
Why that's correct here: Unitree already motion-corrects (deskews) the cloud,
12+
so there is no intra-scan timing to preserve; with time==0 the VELO16 handler
13+
synthesizes a harmless yaw-based offset, and ring 0 (< scan_line) passes its
14+
filter. This keeps the upstream Point-LIO fork completely unmodified — all the
15+
Go2-specific adaptation lives here, in fast-to-iterate Python.
16+
"""
17+
import os
18+
19+
import numpy as np
20+
import rclpy
21+
from rclpy.node import Node
22+
from rclpy.qos import QoSPresetProfiles
23+
from sensor_msgs.msg import PointCloud2, PointField
24+
25+
IN_TOPIC = os.environ.get("LIDAR_TOPIC", "/utlidar/cloud_deskewed")
26+
OUT_TOPIC = os.environ.get("SHIM_OUT_TOPIC", "/point_lio/cloud")
27+
28+
# velodyne_ros::Point wire layout Point-LIO expects (matched by field NAME by
29+
# pcl::fromROSMsg, so a compact self-consistent packing is fine).
30+
_OUT_STEP = 24
31+
_OUT_FIELDS = [
32+
PointField(name="x", offset=0, datatype=PointField.FLOAT32, count=1),
33+
PointField(name="y", offset=4, datatype=PointField.FLOAT32, count=1),
34+
PointField(name="z", offset=8, datatype=PointField.FLOAT32, count=1),
35+
PointField(name="intensity", offset=12, datatype=PointField.FLOAT32, count=1),
36+
PointField(name="time", offset=16, datatype=PointField.FLOAT32, count=1),
37+
PointField(name="ring", offset=20, datatype=PointField.UINT16, count=1),
38+
]
39+
_OUT_DTYPE = np.dtype({
40+
"names": ["x", "y", "z", "intensity", "time", "ring"],
41+
"formats": ["<f4", "<f4", "<f4", "<f4", "<f4", "<u2"],
42+
"offsets": [0, 4, 8, 12, 16, 20],
43+
"itemsize": _OUT_STEP,
44+
})
45+
46+
_NP = { # PointField datatype -> numpy scalar
47+
PointField.INT8: "<i1", PointField.UINT8: "<u1",
48+
PointField.INT16: "<i2", PointField.UINT16: "<u2",
49+
PointField.INT32: "<i4", PointField.UINT32: "<u4",
50+
PointField.FLOAT32: "<f4", PointField.FLOAT64: "<f8",
51+
}
52+
53+
54+
def _read_field(raw, step, off, dt, n):
55+
"""Read one named field out of a packed PointCloud2 buffer as float64."""
56+
view = np.frombuffer(raw, dtype=np.uint8).reshape(n, step)[:, off:off + np.dtype(dt).itemsize]
57+
return view.copy().view(dt).reshape(n).astype(np.float64)
58+
59+
60+
class CloudShim(Node):
61+
def __init__(self):
62+
super().__init__("go2_cloud_shim")
63+
qos = QoSPresetProfiles.SENSOR_DATA.value
64+
self.pub = self.create_publisher(PointCloud2, OUT_TOPIC, qos)
65+
self.create_subscription(PointCloud2, IN_TOPIC, self.on_cloud, qos)
66+
self._warned = False
67+
self.get_logger().info(f"cloud_shim: {IN_TOPIC} -> {OUT_TOPIC} (velodyne layout)")
68+
69+
def on_cloud(self, msg: PointCloud2):
70+
n = (len(msg.data) // msg.point_step) if msg.point_step else 0
71+
if n == 0:
72+
return
73+
off = {f.name: (f.offset, _NP.get(f.datatype)) for f in msg.fields}
74+
if "x" not in off or "y" not in off or "z" not in off:
75+
if not self._warned:
76+
self.get_logger().error(f"input cloud lacks x/y/z fields: {list(off)}")
77+
self._warned = True
78+
return
79+
raw = bytes(msg.data)
80+
out = np.zeros(n, dtype=_OUT_DTYPE)
81+
out["x"] = _read_field(raw, msg.point_step, *off["x"], n)
82+
out["y"] = _read_field(raw, msg.point_step, *off["y"], n)
83+
out["z"] = _read_field(raw, msg.point_step, *off["z"], n)
84+
if "intensity" in off and off["intensity"][1]:
85+
out["intensity"] = _read_field(raw, msg.point_step, *off["intensity"], n)
86+
# Per-point time: a tiny monotonic ramp (0 -> 1 ms). The Go2 cloud is
87+
# already deskewed, so the scan is effectively instantaneous — but with
88+
# time==0 Point-LIO's VELO16 handler *synthesizes* a spinning-Velodyne
89+
# timing (omega=3.61 deg/ms), which is wrong for the non-repetitive
90+
# Mid-360 and can diverge the filter. A small >0 ramp makes
91+
# given_offset_time=true so it uses these ~0 values as-is instead.
92+
out["time"] = np.linspace(0.0, 1e-3, n, dtype=np.float32)
93+
# ring stays 0 (single logical ring; < scan_line so it passes the filter)
94+
95+
m = PointCloud2()
96+
m.header = msg.header
97+
m.height = 1
98+
m.width = n
99+
m.fields = _OUT_FIELDS
100+
m.is_bigendian = False
101+
m.point_step = _OUT_STEP
102+
m.row_step = _OUT_STEP * n
103+
m.is_dense = True
104+
m.data = out.tobytes()
105+
self.pub.publish(m)
106+
107+
108+
def main():
109+
rclpy.init()
110+
rclpy.spin(CloudShim())
111+
112+
113+
if __name__ == "__main__":
114+
main()

python/go2-mapper/go2-slam/config/mid360_go2.yaml

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Point-LIO config for the Unitree Go2 EDU onboard Mid-360.
22
#
3-
# Input is Unitree's ALREADY-DESKEWED PointCloud2, so lidar_type is the
4-
# generic PointCloud2 path, not the livox custom-msg path.
3+
# The Go2's /utlidar/cloud_deskewed carries only x,y,z,intensity (no ring /
4+
# per-point time), which Point-LIO's stock handlers can't parse. cloud_shim.py
5+
# repacks it into the Velodyne layout on /point_lio/cloud, so we consume that
6+
# topic via lidar_type=2 (VELO16). See cloud_shim.py for the rationale.
57
#
68
# Extrinsics: identity rotation, lidar ~0.4 m above ground on the Go2 head
79
# mount. Point-LIO estimates gravity from the IMU, so a rough translation
@@ -10,18 +12,19 @@
1012
/**:
1113
ros__parameters:
1214
common:
13-
lid_topic: "/utlidar/cloud_deskewed" # overridden by entry.sh from env
14-
imu_topic: "/utlidar/imu"
15+
lid_topic: "/point_lio/cloud" # cloud_shim.py output (repacked from /utlidar/cloud_deskewed)
16+
imu_topic: "/utlidar/imu" # overridden by entry.sh from IMU_TOPIC env
1517
con_frame: false
1618
con_frame_num: 1
1719
cut_frame: false
1820
cut_frame_time_interval: 0.1
1921
time_diff_lidar_to_imu: 0.0
2022

2123
preprocess:
22-
lidar_type: 5 # 5 = generic PointCloud2 in Point-LIO's enum; verify against the release you build
23-
scan_line: 4
24-
timestamp_unit: 3 # nanoseconds
24+
lidar_type: 2 # 2 = VELO16 (Velodyne PointCloud2 path); cloud_shim emits this layout
25+
scan_line: 16 # N_SCANS; shim tags every point ring=0, so any value >0 passes the filter
26+
point_filter_num: 3 # keep every 3rd point (~11k -> ~3.7k/scan) to bound CPU on the Orin
27+
timestamp_unit: 3 # nanoseconds (unused: shim sets time=0, VELO16 then synthesizes offsets)
2528
blind: 0.5 # drop returns closer than 0.5 m (the dog's own body)
2629

2730
mapping:
@@ -51,5 +54,5 @@
5154
scan_bodyframe_pub_en: false
5255

5356
pcd_save:
54-
pcd_save_en: true # periodic full-map save; console exports from this
55-
interval: -1 # -1 = accumulate everything into one map
57+
pcd_save_en: false # viz_bridge.py owns snapshots (finer voxel + dynamic culling)
58+
interval: -1
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
#!/bin/bash
2-
# go2-slam entrypoint: Point-LIO + Foxglove bridge + viz/pose sidecar.
2+
# go2-slam entrypoint: cloud shim + Point-LIO + Foxglove bridge + viz/pose sidecar.
33
set -e
44
source /opt/ros/humble/setup.bash
55
source /ws/install/setup.bash
66

7-
# Patch topics from env into the config copy (keeps template.json the single source)
7+
# lid_topic is fixed to the shim output (/point_lio/cloud) in the yaml; only the
8+
# IMU topic is env-tunable per device.
89
CFG=/tmp/mid360_go2.yaml
910
cp /ws/config/mid360_go2.yaml "$CFG"
10-
sed -i "s|lid_topic:.*|lid_topic: \"${LIDAR_TOPIC:-/utlidar/cloud_deskewed}\"|" "$CFG"
1111
sed -i "s|imu_topic:.*|imu_topic: \"${IMU_TOPIC:-/utlidar/imu}\"|" "$CFG"
1212

13+
# Repack the Go2's xyz+intensity cloud -> Velodyne layout Point-LIO can ingest.
14+
LIDAR_TOPIC="${LIDAR_TOPIC:-/utlidar/cloud_deskewed}" python3 /opt/cloud_shim.py &
15+
# Live map view for Foxglove.
1316
ros2 run foxglove_bridge foxglove_bridge --ros-args -p port:=${FOXGLOVE_PORT:-8765} &
17+
# viz/pose/health/snapshot/keyframe sidecar (robustness features).
1418
python3 /opt/viz_bridge.py &
1519
exec ros2 run point_lio pointlio_mapping --ros-args --params-file "$CFG"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
cmake_minimum_required(VERSION 3.8)
2+
project(livox_ros_driver2)
3+
4+
find_package(ament_cmake REQUIRED)
5+
find_package(std_msgs REQUIRED)
6+
find_package(rosidl_default_generators REQUIRED)
7+
8+
rosidl_generate_interfaces(${PROJECT_NAME}
9+
"msg/CustomPoint.msg"
10+
"msg/CustomMsg.msg"
11+
DEPENDENCIES std_msgs
12+
)
13+
14+
ament_package()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
std_msgs/Header header
2+
uint64 timebase
3+
uint32 point_num
4+
uint8 lidar_id
5+
uint8[3] rsvd
6+
CustomPoint[] points
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
uint32 offset_time
2+
float32 x
3+
float32 y
4+
float32 z
5+
uint8 reflectivity
6+
uint8 tag
7+
uint8 line
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0"?>
2+
<package format="3">
3+
<name>livox_ros_driver2</name>
4+
<version>1.0.0</version>
5+
<description>
6+
Minimal vendored CustomMsg/CustomPoint so Point-LIO compiles. The Livox
7+
custom-message path is never used at runtime — the Go2 publishes a standard
8+
PointCloud2 and we run Point-LIO via the VELO16 path (see cloud_shim.py) —
9+
but Point-LIO includes livox_ros_driver2/msg/custom_msg.hpp unconditionally,
10+
so the type must exist at build time. Fields mirror the upstream driver.
11+
</description>
12+
<maintainer email="noreply@wendy.sh">go2-mapper</maintainer>
13+
<license>MIT</license>
14+
15+
<buildtool_depend>ament_cmake</buildtool_depend>
16+
<buildtool_depend>rosidl_default_generators</buildtool_depend>
17+
18+
<depend>std_msgs</depend>
19+
20+
<exec_depend>rosidl_default_runtime</exec_depend>
21+
<member_of_group>rosidl_interface_packages</member_of_group>
22+
</package>

0 commit comments

Comments
 (0)