Scan Fusion——激光雷达和深度相机怎么合成一条扫描线

巡检车上装了两个距离传感器:MS200 激光雷达和 Aurora 930 Pro 深度相机。激光雷达 360° 扫描但前方有盲区,深度相机只看前方但近距离精度高。SLAM 和 Nav2 需要一条统一的 /scan 话题——把两个传感器的数据合成一条扫描线,就是 Scan Fusion 要干的活。

听起来简单:把深度相机的点投影到雷达坐标系,重叠区域取最近的。但实际做起来有三个坑:坐标系变换、点数不一致、和那个让 SLAM 崩溃的可变长度问题。

融合逻辑:前方 ±70° 取最近

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def _fuse_scan_ranges(self, raw, depth, transform):
fused = list(raw.ranges) # 以雷达扫描为底

# 深度相机每个点:坐标变换 → 投影到雷达角度 → 取最近
for r in depth.ranges:
# 1. 深度相机坐标系下的点
x_d = r * math.cos(angle)
y_d = r * math.sin(angle)

# 2. 变换到雷达坐标系
x_r = c * x_d - s * y_d + tx
y_r = s * x_d + c * y_d + ty
range_r = math.hypot(x_r, y_r)
angle_r = math.atan2(y_r, x_r)

# 3. 超出前方 ±70° 丢弃
if abs(angle_r) > self._fuse_front_half_angle_rad:
continue

# 4. 投射到雷达对应的角度 bin
idx = int((angle_r - raw_angle_min) / raw_angle_inc + 0.5)

# 5. 取最近障碍物
current = fused[idx]
if (not math.isfinite(current)) or (range_r < current):
fused[idx] = float(range_r)

return fused

五步流水线:深度点 → 坐标变换 → 角度过滤 → bin 映射 → 最近优先。

min 融合策略很关键:如果雷达说前方 1.5m 有东西,深度相机说 0.3m 有东西,那 0.3m 是对的——深度相机在近距离更准。反过来,如果雷达说有东西但深度没检测到,雷达的值保留——深度相机可能因为反光丢了数据。

坐标变换:TF2 自动查

深度相机和雷达不在同一个位置,中间有个物理偏移和旋转。ROS2 的 TF2 系统维护了所有坐标系的变换关系:

1
2
3
4
5
6
7
8
9
10
11
try:
transform = self._tf_buffer.lookup_transform(
raw.header.frame_id, # 目标:雷达坐标系
depth.header.frame_id, # 源:深度相机坐标系
rclpy.time.Time(),
timeout=rclpy.duration.Duration(seconds=0.05),
)
except TransformException:
# TF 不可用,退化为纯雷达模式
self._scan_pub.publish(self._build_output_scan(raw, list(raw.ranges), ...))
return

TF 不可用时不融合,直接透传原始雷达数据——降级而不是崩溃。

固定点数:SLAM 的隐性要求

这是最大的坑。SLAM 算法(slam_toolbox)在初始化时记录第一条 LaserScan 的 angle_minangle_maxangle_incrementranges 长度,后续所有帧必须跟第一帧完全一致。如果某一帧的 ranges 少了几个点,SLAM 直接崩溃。

问题是:深度相机的点数会变。相机帧率波动时,深度转激光扫描的输出点数可能从 360 变成 358 或 362。融合后的点数跟着变——SLAM 就炸了。

解法:强制固定点数,用最近邻重采样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def _normalize_scan_payload(self, template, ranges, intensities):
if not self._enforce_fixed_scan_size:
return ranges, intensities

# 确定目标点数
if self._fixed_scan_size > 0:
target_size = self._fixed_scan_size
else:
# 第一帧的点数作为标准
if self._expected_scan_size <= 0:
self._expected_scan_size = max(1, len(ranges))
target_size = self._expected_scan_size

normalized_ranges = self._resample_nearest(ranges, target_size, fill=template.range_max)
normalized_intensities = self._resample_nearest(intensities, target_size, fill=0.0)
return normalized_ranges, normalized_intensities

最近邻重采样:

1
2
3
4
5
6
7
8
9
10
11
12
@staticmethod
def _resample_nearest(values, target_size, fill):
src = len(values)
if src == target_size:
return list(values)

scale = float(src - 1) / float(target - 1)
out = []
for i in range(target):
idx = int(round(i * scale))
out.append(float(values[idx]))
return out

从 358 点到 360 点:多出来的 2 个位置通过线性插值找最近的原始点填充。从 362 到 360:多余的 2 个点被均匀丢弃。变化在 ±2 点以内时,对精度几乎没有影响。

深度超时:相机卡了就别用

深度相机偶尔会卡帧——ROS topic 几百毫秒没有新消息。如果用过期的深度数据融合,车已经走了 0.5 米了,融合出来的障碍物位置全错。

1
2
3
if depth is None or depth_age > self._depth_timeout_sec:  # 0.4 秒
self._scan_pub.publish(self._build_output_scan(raw, list(raw.ranges), ...))
return

0.4 秒超时。深度数据超过 0.4 秒未更新就丢弃,退化为纯雷达模式。这个阈值比雷达扫描周期(~100ms)大不少,留了余量处理网络延迟。

运行时热更新

所有融合参数都可以通过 /agri/runtime_config 话题热更新——不用重启节点就能开关融合、调整角度范围、修改最小距离。

1
2
3
4
5
6
7
8
9
def _on_runtime_config(self, msg):
payload = json.loads(msg.data)
self._apply_runtime_config_payload(payload)

def _apply_runtime_config_payload(self, payload):
fusion_cfg = runtime_cfg.get('scan_fusion')
self._depth_fusion_enabled = bool(fusion_cfg.get('depth_fusion_enabled', ...))
self._fuse_front_half_angle_deg = max(0.0, float(fusion_cfg.get('fuse_front_half_angle_deg', ...)))
...

带版本号和签名去重,同一个配置不会重复应用。这在调试时特别有用——现场发现融合角度太窄导致漏检,改个配置就生效,不用 SSH 上去重启 Docker。

效果

指标纯雷达雷达 + 深度融合
前方最小检测距离~0.3m~0.1m
低矮障碍检测容易漏(激光扫过去)能检测到
玻璃/反光表面检测不到看深度相机
SLAM 稳定性稳定固定点数后稳定
CPU 开销0+3%(重采样+融合)

3% 的 CPU 换来前方近距离检测能力的质变,在树莓派上完全可以接受。

欢迎关注我的其它发布渠道