一句话总结

用两种不同型号的 LiDAR 传感器融合 + 可学习重排序策略,解决农业场景中”到处都长得一样”导致的位置识别失效问题。


为什么这个问题重要?

想象一台农业机器人在葡萄园里工作:每一行葡萄藤看起来几乎一模一样,而且随着季节变化,春天的嫩芽和夏天的茂叶完全是两个世界。这种结构高度重复 + 外观跨季变化的组合,是位置识别(Place Recognition)算法的噩梦。

位置识别是自主系统的核心能力——机器人需要知道”我之前来过这里吗?”。这直接关系到:

  • SLAM 的回环检测(Loop Closure)
  • 长期自主导航的定位漂移修正
  • 多机器人协同地图共享

现有方法的问题

  • 基于视觉的方法对光照敏感,田野里阳光直射分分钟失效
  • 单个 LiDAR 的视野和点云密度有限
  • 葡萄园行列结构极度重复,初步检索容易混淆相邻行

这篇论文的创新:MinkUNeXt-VINE++ 把两个不同型号的 LiDAR 做”早融合”(Early Fusion),再用一个可学习的重排序模块在推理时精化结果,Recall@1 相比单传感器方案提升 20%,加上重排序后达到 +30%。


背景知识

两种 LiDAR 的互补性

传感器 类型 特点 缺点
Livox Mid-360 固态 LiDAR 360° 覆盖,中心区域点云极密,非重复扫描 非均匀分布,远处稀疏
Velodyne VLP-16 旋转 LiDAR 16 线,均匀分布,360° 水平覆盖 点云稀疏,垂直分辨率低

这两个传感器是天然的互补关系——Livox 给你精细的局部结构,Velodyne 给你均匀的全局轮廓。

早融合 vs 晚融合

早融合(本文采用):
原始点云A ─┐
            ├→ [合并] → [单个特征提取器] → 全局描述子
原始点云B ─┘

晚融合:
原始点云A → [特征提取器A] ─┐
                              ├→ [融合层] → 全局描述子
原始点云B → [特征提取器B] ─┘

早融合的优势:一次前向传播,网络可以学习跨传感器的空间关系;劣势:需要精确的外参标定。

位置识别的基本流程

建图阶段:
场景点云 → 特征提取 → 全局描述子 → 存入数据库

查询阶段:
当前点云 → 特征提取 → 全局描述子 → 最近邻搜索 → Top-K 候选 → [重排序] → 最终结果

核心方法

直觉解释

第一步:传感器融合——把两个 LiDAR 的点云对齐到同一坐标系(通过外参标定),然后直接拼接,同时附加一个传感器来源标签,让网络知道每个点来自哪个传感器。

第二步:稀疏卷积特征提取——农业场景的点云范围大、点云稀疏,MinkowskiEngine 的稀疏卷积是标配,只在有点的地方做计算。

第三步:重排序——Top-1 检索结果错了,但 Top-5 里有正确答案?重排序就是让一个小网络重新审视这些候选,利用候选之间的相互关系来修正排名。葡萄园里,真正的正样本通常在局部几何上和查询更接近,这种关系可以被学习。

关键数学

描述子相似度(初步检索)

\[\text{sim}(q, d_i) = \frac{q \cdot d_i}{\|q\|_2 \cdot \|d_i\|_2}\]

重排序评分:对 Top-K 候选 ${d_1, …, d_K}$,构造包含查询-候选关系和候选间关系的特征:

\[\phi(q, d_i) = [q \,\|\, d_i \,\|\, q - d_i \,\|\, q \odot d_i]\]

再用学习到的评分函数 $f_\theta: \mathbb{R}^{4D} \to \mathbb{R}$ 输出精化分数。


实现

异构 LiDAR 融合核心

import numpy as np
import torch

def fuse_heterogeneous_lidar(pc_livox: np.ndarray, 
                              pc_velodyne: np.ndarray,
                              T_livox_to_velodyne: np.ndarray) -> np.ndarray:
    """
    异构 LiDAR 早融合
    Args:
        pc_livox: (N1, 4) [x, y, z, intensity] Livox 点云
        pc_velodyne: (N2, 4) [x, y, z, intensity] Velodyne 点云
        T_livox_to_velodyne: (4, 4) 外参矩阵,将 Livox 坐标系转到 Velodyne
    Returns:
        fused: (N1+N2, 5) 融合点云,最后一列为传感器标签
    """
    # 1. 外参对齐:将 Livox 点云变换到 Velodyne 坐标系
    pts_livox_hom = np.hstack([pc_livox[:, :3], 
                                np.ones((len(pc_livox), 1))])  # (N1, 4)
    pts_livox_aligned = (T_livox_to_velodyne @ pts_livox_hom.T).T[:, :3]  # (N1, 3)
    
    # 2. 重新组合带强度的点云
    pc_livox_aligned = np.hstack([pts_livox_aligned, pc_livox[:, 3:4]])  # (N1, 4)
    
    # 3. 添加传感器来源标签(0=Livox, 1=Velodyne)
    label_livox = np.zeros((len(pc_livox_aligned), 1))
    label_velodyne = np.ones((len(pc_velodyne), 1))
    
    # 4. 拼接融合
    fused = np.vstack([
        np.hstack([pc_livox_aligned, label_livox]),
        np.hstack([pc_velodyne, label_velodyne])
    ])  # (N1+N2, 5)
    
    return fused


def voxelize(points: np.ndarray, voxel_size: float = 0.1):
    """体素化:将连续点云离散化为稀疏体素,供 MinkowskiEngine 使用"""
    coords = np.floor(points[:, :3] / voxel_size).astype(np.int32)
    
    # 去重:每个体素保留一个点(取均值)
    unique_coords, inverse = np.unique(coords, axis=0, return_inverse=True)
    num_voxels = len(unique_coords)
    
    feats = np.zeros((num_voxels, points.shape[1] - 3))
    np.add.at(feats, inverse, points[:, 3:])
    counts = np.bincount(inverse, minlength=num_voxels)
    feats /= counts[:, None]
    
    return unique_coords, feats  # 坐标 + 特征

稀疏卷积特征提取(MinkUNeXt 骨干)

import MinkowskiEngine as ME

class SparseConvBlock(ME.MinkowskiNetwork):
    """ConvNeXt 风格的稀疏卷积块"""
    def __init__(self, channels: int, D: int = 3):
        super().__init__(D)
        # 深度可分离卷积(depthwise)
        self.dw_conv = ME.MinkowskiDepthwiseConvolution(
            channels, kernel_size=7, stride=1, dimension=D)
        self.norm = ME.MinkowskiLayerNorm(channels)
        # 逐点扩张(pointwise)
        self.pw1 = ME.MinkowskiLinear(channels, 4 * channels)
        self.pw2 = ME.MinkowskiLinear(4 * channels, channels)
        self.act = ME.MinkowskiGELU()
    
    def forward(self, x: ME.SparseTensor) -> ME.SparseTensor:
        residual = x
        x = self.dw_conv(x)
        x = self.norm(x)
        x = self.pw2(self.act(self.pw1(x)))
        return x + residual  # 残差连接


class MinkUNeXtEncoder(ME.MinkowskiNetwork):
    """编码器:提取多尺度稀疏特征,输出全局描述子"""
    def __init__(self, in_channels: int = 2, feat_dim: int = 256, D: int = 3):
        super().__init__(D)
        self.stem = ME.MinkowskiConvolution(in_channels, 32, kernel_size=3, dimension=D)
        
        # 下采样阶段(类 UNet 结构省略上采样分支)
        self.stage1 = SparseConvBlock(32, D)
        self.down1 = ME.MinkowskiConvolution(32, 64, kernel_size=2, stride=2, dimension=D)
        self.stage2 = SparseConvBlock(64, D)
        self.down2 = ME.MinkowskiConvolution(64, 128, kernel_size=2, stride=2, dimension=D)
        self.stage3 = SparseConvBlock(128, D)
        
        # 全局平均池化 → 全局描述子
        self.global_pool = ME.MinkowskiGlobalAvgPooling()
        self.proj = torch.nn.Linear(128, feat_dim)
    
    def forward(self, x: ME.SparseTensor) -> torch.Tensor:
        x = self.stem(x)
        x = self.stage1(x)
        x = self.down1(x)
        x = self.stage2(x)
        x = self.down2(x)
        x = self.stage3(x)
        
        x = self.global_pool(x)  # (B, 128)
        desc = self.proj(x.F)    # (B, feat_dim)
        return torch.nn.functional.normalize(desc, dim=-1)  # L2 归一化

可学习重排序策略

class LearnedReranker(torch.nn.Module):
    """
    输入:查询描述子 + K 个候选描述子
    输出:K 个精化分数
    """
    def __init__(self, feat_dim: int = 256, hidden: int = 512):
        super().__init__()
        # 输入特征:[q, d_i, q-d_i, q⊙d_i] → 4*feat_dim
        self.mlp = torch.nn.Sequential(
            torch.nn.Linear(4 * feat_dim, hidden),
            torch.nn.LayerNorm(hidden),
            torch.nn.ReLU(),
            torch.nn.Linear(hidden, hidden // 2),
            torch.nn.ReLU(),
            torch.nn.Linear(hidden // 2, 1)
        )
    
    def forward(self, query: torch.Tensor, 
                candidates: torch.Tensor) -> torch.Tensor:
        """
        query: (D,) 或 (B, D)
        candidates: (K, D) 或 (B, K, D)
        returns: (K,) 或 (B, K) 精化分数
        """
        K = candidates.shape[-2]
        q_exp = query.unsqueeze(-2).expand_as(candidates)  # 扩展维度对齐
        
        # 构造查询-候选交互特征
        pair_feat = torch.cat([
            q_exp,                    # 查询自身
            candidates,               # 候选描述子
            q_exp - candidates,       # 差向量(方向性)
            q_exp * candidates,       # 逐元素乘积(相关性)
        ], dim=-1)  # (..., K, 4D)
        
        scores = self.mlp(pair_feat).squeeze(-1)  # (..., K)
        return scores


def rerank_candidates(query_desc: torch.Tensor,
                      db_descs: torch.Tensor,
                      top_k: int = 25,
                      reranker: LearnedReranker = None) -> torch.Tensor:
    """完整的检索 + 重排序 Pipeline"""
    # Step 1:初步检索(余弦相似度)
    sims = query_desc @ db_descs.T  # (N_db,)
    top_k_idx = sims.topk(top_k).indices  # (K,)
    top_k_descs = db_descs[top_k_idx]     # (K, D)
    
    # Step 2:可学习重排序
    refined_scores = reranker(query_desc, top_k_descs)  # (K,)
    refined_order = refined_scores.argsort(descending=True)
    
    return top_k_idx[refined_order]  # 重排序后的索引

评估指标(Recall@N)

def recall_at_n(query_descs: np.ndarray, 
                db_descs: np.ndarray,
                query_poses: np.ndarray, 
                db_poses: np.ndarray,
                n_values: list = [1, 5, 10],
                pos_threshold: float = 5.0) -> dict:
    """
    计算 Recall@N:Top-N 中至少有一个真正例的查询比例
    pos_threshold: 判断为正样本的距离阈值(米)
    """
    sims = query_descs @ db_descs.T  # (Nq, Ndb)
    recalls = {n: 0 for n in n_values}
    
    for i, (q_desc, q_pose) in enumerate(zip(query_descs, query_poses)):
        top_n_idx = np.argsort(sims[i])[::-1][:max(n_values)]
        gt_mask = np.linalg.norm(db_poses - q_pose, axis=1) < pos_threshold
        
        for n in n_values:
            if gt_mask[top_n_idx[:n]].any():
                recalls[n] += 1
    
    return {f"R@{n}": recalls[n] / len(query_descs) for n in n_values}

实验

数据集:TEMPO-VINE

TEMPO-VINE 是专门为农业场景设计的数据集,包含:

  • 多个物候阶段(冬季修剪期 → 春季萌芽 → 夏季茂盛)
  • 同时采集 Livox Mid-360 + Velodyne VLP-16 数据
  • 提供 GPS/RTK 真值位姿

这个数据集的难点在于跨季节查询:用夏季数据查冬季建立的地图,点云外观差异极大。

定量结果

方法 传感器 Recall@1 Recall@5
PointNetVLAD Velodyne 38.2% 61.4%
MinkLoc3Dv2 Velodyne 51.7% 72.3%
MinkUNeXt-VINE 单传感器 58.1% 78.6%
MinkUNeXt-VINE++ 双传感器融合 69.7% 85.2%
+重排序 双传感器融合 75.5% 89.1%

20%~30% 的提升在位置识别领域是相当显著的。


工程实践

外参标定是硬性门槛

早融合成败的关键在于两个 LiDAR 的外参是否准确。标定误差 >5cm 会导致融合后点云出现”重影”。

# 标定质量快速检查:融合后同一平面点应高度共面
def check_calibration_quality(fused_pc: np.ndarray, 
                               plane_height_range: tuple = (0.0, 0.05)):
    """提取地面点,检查融合后的平面拟合残差"""
    ground_pts = fused_pc[(fused_pc[:, 2] > plane_height_range[0]) & 
                           (fused_pc[:, 2] < plane_height_range[1])]
    # 用 RANSAC 拟合平面,残差 > 3cm 说明标定有问题
    # ... RANSAC 代码省略
    return residuals.mean()

内存和速度

配置 GPU 显存 推理延迟 Recall@1
仅特征提取 ~2GB 18ms 69.7%
+重排序(K=25) ~2.5GB 26ms 75.5%
+重排序(K=50) ~2.8GB 35ms 76.1%

K=25 是性价比最高的配置,50ms 以内完全满足在线导航需求(不要求实时频率的场景)。

常见坑

  1. 点云时间戳不对齐 → 机器人运动时两个 LiDAR 采集时刻不同,需要运动补偿(Motion Undistortion)后再融合,否则融合点云会有”拖影”

  2. 强度归一化不一致 → Livox 和 Velodyne 的强度值范围不同(0-255 vs 0-1),直接拼接会让网络过度依赖强度特征,需要分别归一化到 [0,1]

  3. 葡萄园行列索引混淆 → 相邻两行点云相似度极高,重排序模块需要足够大的 K(至少 25)才能在候选集中包含正确答案


什么时候用 / 不用?

适用场景 不适用场景
结构重复的农业、工业场景 高动态场景(人群密集区)
有多个异构 LiDAR 传感器 只有单个 LiDAR 且不想改硬件
需要跨季节长期定位 仅需短期(单次任务)定位
机器人速度慢(<5m/s) 高速车辆(时间戳同步要求极高)
离线或松实时(>50ms 可接受) 严格实时控制(<10ms 要求)

与其他方法对比

方法 优点 缺点 适用场景
PointNetVLAD 简单易实现 大场景性能差 小型室内场景
MinkLoc3Dv2 稀疏卷积效率高 单传感器信息有限 一般室外场景
MinkUNeXt-VINE++ 异构融合+重排序 需要两个 LiDAR + 标定 重复结构农业场景
NetVLAD(视觉) 纹理丰富时精度高 光照敏感,跨季节差 纹理丰富、光照稳定场景

我的观点

这篇论文解决的是一个很实际的问题——大多数位置识别论文在 Oxford RobotCar、MulRan 这类城市数据集上刷分,而农业场景的高重复性和跨季节挑战是完全不同的难度等级。

异构传感器融合的趋势很明确:单一传感器的信息上限在复杂场景里已经到顶,多传感器融合是必经之路。但早融合的工程门槛(外参标定、时间戳同步)在真实部署中仍然是重要挑战。

重排序模块是点睛之笔:+10% 的提升来自重排序而非更大的模型,这说明初步检索的错误不是因为特征不够好,而是因为候选集里的正确答案没被排到第一位——这类系统性误差正是可学习重排序擅长修正的。

离实际部署还有什么距离?主要是标注数据的获取——训练重排序模块需要有标注的正负样本对,在新农场、新作物上需要重新采集标注,这是推广到更多场景的瓶颈。自监督或零样本重排序是值得关注的开放问题。

论文代码已开源,感兴趣可以参考 arxiv 页面 中的代码链接。