1. 视觉重定位任务的背景与核心挑战
视觉重定位(Visual Relocalization)这个方向,在SLAM、AR/VR、机器人自主导航里都算是一块硬骨头。简单说,它的目标就是:给定一张当前拍摄的图像,推断出相机在已知场景中的精确位姿,也就是6DOF(6 Degrees of Freedom)——3个位置自由度(x, y, z)加上3个姿态自由度(俯仰、偏航、翻滚)。这件事听起来像是“图像检索+位姿估计”的组合拳,但真正做过的人都知道,坑远比想象中多。
传统方案大致分两条路。一条是基于特征点匹配的几何方法,比如用SIFT、ORB提取关键点,跟预先建好的三维点云做2D-3D对应,再用PnP+RANSAC解位姿。这条路精度高、可解释性强,但极度依赖纹理丰富度和重复纹理的区分度,一旦遇到弱纹理、光照剧变、运动模糊,匹配就崩了。另一条路是基于图像检索的粗定位,把场景切成很多小格子,每格标一个位姿,查询时找最相似的图像,直接返回对应位姿。这种方法鲁棒性稍好,但精度受限于离散化的粒度,格子太密检索容易混,格子太疏精度又不够。
2019年前后,深度学习开始在这个领域发力,核心思路是用卷积神经网络直接回归位姿,或者学习一个度量空间让相似位姿的图像靠得更近。但纯回归的方法泛化性差,换个场景就得重新训;纯检索的方法又丢了几何约束。我当时看到这篇论文标题里的“Relative Geometry-Aware”和“Siamese Neural Network”时,第一反应是:终于有人把“相对几何关系”这个先验显式地塞进孪生网络里了,而不是让网络自己去瞎猜。
这篇论文要解决的核心问题很明确:如何在只有单张查询图像的情况下,既保持检索式方法的鲁棒性,又引入几何一致性约束来提升位姿精度。它适合谁看?如果你正在做AR锚点持久化、机器人重定位、或者任何需要“从一张图找回相机在哪”的系统,这篇笔记里的思路和踩坑经验应该能帮你省不少时间。下面我会从整体设计、核心细节、实操复现、问题排查几个维度,把这篇论文的骨架和血肉都拆开讲一遍。
2. 整体设计思路与方案选型拆解
2.1 为什么选孪生网络而不是单支回归网络
孪生网络(Siamese Network)的结构特点是两支共享权重,输入一对图像,输出一个相似度或者相对变换。放在重定位任务里,这个设计天然适合做“相对位姿估计”:给定查询图像和数据库里的一张参考图像,网络要回答的不是“查询图像的绝对位姿是多少”,而是“查询图像相对于参考图像的位姿偏移是多少”。这个转变非常关键。
绝对位姿回归的问题在于,网络需要隐式地记住整个场景的全局坐标系,这要求极大的模型容量和极多的训练数据。而相对位姿估计把问题局部化了:只要参考图像跟查询图像足够近,它们之间的相对变换就落在一个小范围内,网络学起来容易得多。更重要的是,数据库里每张参考图像的绝对位姿是已知的(建图阶段就标好了),所以一旦网络预测出相对位姿,一个简单的坐标变换就能得到查询图像的绝对位姿。
我实测下来的体会是,这种“相对+已知参考”的范式,在场景规模变大时优势特别明显。绝对回归网络在房间级别还能凑合,一旦扩展到整栋楼,精度就断崖式下跌。而孪生网络因为每次只关心局部相对关系,泛化压力小很多。
2.2 相对几何感知模块到底在感知什么
论文标题里的“Relative Geometry-Aware”是整个方法的核心创新点。它要解决的问题是:普通的孪生网络只学外观相似度,但外观相似的图像未必位姿接近(比如走廊两端看起来几乎一样),外观差异大的图像也可能位姿很近(比如同一位置不同朝向)。所以光靠外观特征做度量学习是不够的,必须把几何关系显式地编码进去。
具体做法是在网络中间层引入一个几何感知模块,它接收两支的特征图,计算它们之间的相对几何变换(比如通过可微分的匹配层或者相关层),然后把这个几何信息作为额外通道拼接到特征上,再送入后续的回归头。这样网络在预测相对位姿时,不仅看到“两张图长什么样”,还看到“两张图之间的几何对应关系长什么样”。
这个设计的精妙之处在于,它没有硬编码任何几何算法(比如直接算本质矩阵),而是让网络自己学习如何从特征对应中提取有用的几何线索。这比纯手工设计的几何管道灵活,又比纯黑盒回归多了可解释的中间表示。
2.3 损失函数的设计取舍
训练这种网络,损失函数的设计直接决定成败。论文里用了多任务损失:一部分是相对位姿的回归损失(通常是位置和姿态分开算,位置用L2,姿态用四元数或欧拉角的L2),另一部分是几何一致性损失,确保网络预测的相对变换跟特征匹配层推断出的几何关系不矛盾。
我复现时发现,姿态损失的权重特别难调。位置误差的单位是米,姿态误差的单位是弧度,两者数值范围差好几个量级。如果直接相加,网络会偏向优化数值大的那一项。常见做法是给姿态损失乘一个系数,或者用对数尺度归一化。论文里没有详细说权重怎么定的,我试了几组,最后发现位置损失权重1.0、姿态损失权重10.0左右比较稳,具体还得看场景尺度。
注意:如果你用的是四元数表示姿态,记得在损失里处理一下符号歧义——q和-q表示同一个旋转,但直接算L2会出问题。通常取两者中距离更小的那个。
3. 核心细节解析与实操要点
3.1 数据准备与参考图像采样策略
孪生网络的训练数据是一对一对的图像。最直接的采样方式是从数据库里随机抽两张,但这样抽出来的对儿大部分位姿差异很大,网络学不到精细的相对变换。更好的策略是:以一张图为锚点,在它周围一定半径内采样正样本对(位姿接近),在半径外采样负样本对(位姿远离)。半径的选取很讲究——太小了网络只学会处理微小位移,太大了又退化成粗检索。
我的经验是,半径应该跟场景的尺度相关。比如室内房间场景,位置半径取0.5米到1米比较合适;姿态半径取15到30度。如果是室外大场景,位置半径可以放到5到10米。论文里没有给出具体数值,但根据它的实验设置反推,室内场景大概用的是0.8米位置半径和20度姿态半径。
另外,参考图像的密度也很关键。建图时如果每隔0.3米拍一张,那查询图像跟最近参考图像的距离最多0.15米,相对位姿范围很小,网络容易学。但如果每隔2米才拍一张,相对位姿范围就大了,网络需要更强的表达能力。我一般建议参考图像间距不要超过期望精度的两倍。
3.2 网络骨干与特征融合方式
论文用的骨干网络是ResNet-34的变体,去掉了最后的全连接层,保留卷积特征图。两支网络分别提取查询图像和参考图像的特征,得到两个H×W×C的特征图。然后几何感知模块对这两个特征图做相关运算,得到一个H×W×H×W的4D相关体,再通过卷积降维成H×W×C'的几何特征图。
这个4D相关体的计算量很大,如果特征图是60×80,那相关体就是60×80×60×80,差不多2300万个元素。实际实现时通常会做降采样,比如把特征图先池化到30×40再算相关,这样相关体降到30×40×30×40,计算量减少到原来的十六分之一。精度损失不大,但速度提升明显。
特征融合时,论文把外观特征和几何特征在通道维度拼接,然后过几层卷积做融合。我试过用加法融合和注意力融合,发现拼接+卷积的效果最稳,可能是因为拼接保留了两种特征的独立性,让后续卷积自己决定怎么组合。
3.3 位姿回归头的设计细节
回归头接在融合特征后面,通常是几层全连接或者全局池化+全连接。输出是一个7维向量:3维位置+4维四元数。这里有个坑:四元数必须归一化,否则网络可能输出模长不为1的“伪四元数”,导致旋转矩阵计算错误。常见做法是在输出层加一个L2归一化,或者在损失里加一个模长约束项。
位置输出的尺度也需要注意。如果场景坐标范围是0到10米,网络输出直接回归这个范围没问题。但如果场景坐标是UTM坐标那种几十万米的量级,直接回归会导致数值不稳定。解决办法是先把场景坐标平移到以参考图像为中心,这样相对位置就在几米范围内,回归起来稳定得多。
实操心得:我习惯在回归头最后加一个tanh激活,把输出限制在[-1,1],然后再乘以一个预设的最大范围(比如位置±2米,姿态±180度)。这样网络不会输出离谱的值,训练也更稳定。
3.4 训练策略与学习率调度
孪生网络的训练比单支网络慢,因为每次前向要跑两支。为了加速,我通常先用单支网络在分类任务上预训练骨干,然后再搭孪生结构做微调。学习率用余弦退火,初始值1e-4,最低降到1e-6。Batch size尽量大,因为对比学习类的任务对batch size敏感,太小了负样本不够多样。
还有一个细节:正负样本的比例要控制。如果正样本太多,网络会倾向于把所有对儿都判为“接近”,回归出来的相对位姿趋近于零。如果负样本太多,网络又学不到精细的局部变换。我一般用1:1到1:2的正负比,具体看场景的重复纹理程度。重复纹理多的场景(比如办公楼走廊),负样本比例可以高一点,逼网络学会区分外观相似但位置不同的情况。
4. 实操过程与核心环节实现
4.1 环境搭建与依赖版本
我复现时用的环境是Python 3.7 + PyTorch 1.4 + CUDA 10.1。这个组合在2019年比较主流,现在看有点老,但论文的原始实现就是基于这个版本,为了减少兼容性问题,我建议尽量贴近。如果你用更新的PyTorch,注意几个API变化:torch.nn.functional.grid_sample的align_corners参数默认值变了,torch.optim.lr_scheduler的接口也有调整。
数据加载用torch.utils.data.Dataset自定义一个PairDataset,每次返回查询图像、参考图像、相对位姿真值。图像预处理包括缩放(短边缩到256,中心裁剪224)、归一化(ImageNet均值方差)。相对位姿真值从绝对位姿算:T_rel = T_ref^{-1} * T_query,然后分解成位置和四元数。
import torch import torch.nn as nn import torch.nn.functional as F class PairDataset(torch.utils.data.Dataset): def __init__(self, db_images, db_poses, query_images, query_poses, radius_pos=0.8, radius_rot=20): self.db_images = db_images self.db_poses = db_poses self.query_images = query_images self.query_poses = query_poses self.radius_pos = radius_pos self.radius_rot = radius_rot self.pairs = self._build_pairs() def _build_pairs(self): pairs = [] for qi, qpose in enumerate(self.query_poses): for di, dpose in enumerate(self.db_poses): rel_pos = np.linalg.norm(qpose[:3] - dpose[:3]) rel_rot = compute_rotation_angle(qpose[3:], dpose[3:]) if rel_pos < self.radius_pos and rel_rot < self.radius_rot: pairs.append((qi, di, 'pos')) elif rel_pos > 2 * self.radius_pos or rel_rot > 2 * self.radius_rot: pairs.append((qi, di, 'neg')) return pairs4.2 网络搭建与几何感知模块实现
骨干网络用ResNet-34,去掉最后的平均池化和全连接,保留到layer4的输出。两支共享权重。几何感知模块的核心是相关层:
class GeometryAwareModule(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.corr_conv = nn.Sequential( nn.Conv2d(in_channels * 2, out_channels, 3, padding=1), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) self.fuse_conv = nn.Sequential( nn.Conv2d(in_channels + out_channels, out_channels, 3, padding=1), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def forward(self, feat_q, feat_r): # feat_q, feat_r: [B, C, H, W] B, C, H, W = feat_q.shape # 计算相关体(简化版:逐像素点积后拼接) corr = torch.einsum('bchw,bchw->bhw', feat_q, feat_r).unsqueeze(1) corr = corr.expand(-1, C, -1, -1) # 拼接外观特征和几何特征 fused = torch.cat([feat_q, corr], dim=1) return self.fuse_conv(fused)实际论文里的相关体计算更复杂,会考虑空间维度的对应关系,但核心思想是一样的:让网络看到两支特征之间的交互。
4.3 训练循环与损失计算
训练循环里,每个batch包含若干正样本对和负样本对。正样本对计算相对位姿回归损失,负样本对只计算对比损失(让它们的特征距离拉大)。损失函数:
def compute_loss(pred_pos, pred_quat, gt_pos, gt_quat, is_pos, w_pos=1.0, w_rot=10.0, w_contrast=0.5): # 位置损失 loss_pos = F.mse_loss(pred_pos, gt_pos, reduction='none').sum(dim=1) # 姿态损失(处理四元数符号歧义) dot = (pred_quat * gt_quat).sum(dim=1) gt_quat_aligned = torch.where(dot.unsqueeze(1) < 0, -gt_quat, gt_quat) loss_rot = F.mse_loss(pred_quat, gt_quat_aligned, reduction='none').sum(dim=1) # 只对正样本对计算回归损失 mask = is_pos.float() loss_reg = (w_pos * loss_pos + w_rot * loss_rot) * mask loss_reg = loss_reg.sum() / (mask.sum() + 1e-6) return loss_reg训练时用Adam优化器,初始学习率1e-4,每10个epoch降一半。总共训50到80个epoch,看验证集上的位姿误差收敛情况。
4.4 推理阶段与位姿恢复
推理时,给定查询图像,先跟数据库里所有参考图像算特征相似度,取Top-K个最相似的参考图像。然后对每个参考图像,用孪生网络预测相对位姿,再通过参考图像的绝对位姿恢复查询图像的绝对位姿。最后对这K个候选位姿做聚类或者加权平均,得到最终结果。
这里有个加速技巧:数据库大的时候,没必要跟所有参考图像都跑一遍孪生网络。先用全局特征做粗检索,取Top-20,再对这20个跑精细的相对位姿回归。这样速度能快一个数量级,精度损失很小。
def relocalize(query_img, db_images, db_poses, model, topk=20): # 粗检索 query_feat = model.extract_global_feat(query_img) db_feats = model.extract_global_feat_batch(db_images) sims = F.cosine_similarity(query_feat.unsqueeze(0), db_feats, dim=1) topk_idx = sims.topk(topk).indices # 精细回归 candidates = [] for idx in topk_idx: ref_img = db_images[idx] ref_pose = db_poses[idx] rel_pose = model.predict_relative_pose(query_img, ref_img) abs_pose = compose_pose(ref_pose, ref_pose) candidates.append(abs_pose) # 聚类取最优 final_pose = cluster_poses(candidates) return final_pose5. 常见问题与排查技巧实录
5.1 训练损失不下降或震荡严重
这是最常见的问题。我遇到过的原因有几种:一是学习率太大,孪生网络对学习率比单支网络敏感,建议从1e-5开始试;二是正负样本比例失衡,如果正样本占90%以上,网络会退化成“恒输出零相对位姿”,这时候要重新采样;三是四元数符号歧义没处理,导致姿态损失忽大忽小,加一个符号对齐操作就能解决。
还有一个隐蔽的原因:参考图像和查询图像的预处理不一致。比如参考图像用了中心裁剪,查询图像用了随机裁剪,那网络学到的特征分布就对不上。确保训练和推理时的预处理管道完全一致。
5.2 位姿精度在某个方向上特别差
如果发现位置误差在某个轴向上明显大于其他轴,通常是场景在该方向上纹理重复度高,网络区分不开。比如长走廊场景,沿走廊方向的位移很难从单张图像判断。解决办法是增加该方向上的参考图像密度,或者在损失里给该方向的位置误差加权。
姿态误差如果集中在偏航角(yaw),说明网络对水平旋转不敏感。可以尝试在数据增强里加入更多的水平旋转,或者在几何感知模块里显式地计算特征在水平方向上的位移。
5.3 推理速度太慢
孪生网络推理时要跑两支,如果数据库有几千张参考图像,逐个跑一遍不现实。我常用的加速方案是:先用一个轻量级的全局描述子(比如NetVLAD或者GeM池化后的特征)做粗检索,取Top-20,再跑孪生网络。粗检索可以用FAISS做近似最近邻,几毫秒就能完成。精细回归只跑20次,总耗时控制在100毫秒以内,满足实时性要求。
另一个加速点是降低输入分辨率。论文里用的是224×224,我试过降到160×160,精度只掉了一点点,但速度提升了近一倍。如果场景纹理丰富,甚至可以降到128×128。
5.4 跨场景泛化能力差
在一个场景训好的模型,换到另一个场景精度暴跌,这是深度学习方法的通病。论文里的解决方案是在多个场景上联合训练,让网络学到更通用的几何感知能力。我实测下来,联合训练确实能提升泛化性,但前提是各场景的尺度要归一化,否则网络会被不同尺度的位置误差搞晕。
如果没法联合训练,另一个办法是冻结骨干网络,只微调几何感知模块和回归头。这样需要的训练数据少,过拟合风险也低。我试过用10%的数据微调,精度能恢复到从头训的80%左右。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 损失震荡不收敛 | 学习率过大 | 打印每步损失 | 降低学习率到1e-5 |
| 位姿输出恒为零 | 正样本比例过高 | 统计正负样本比 | 调整采样策略到1:1 |
| 姿态误差忽大忽小 | 四元数符号歧义 | 检查四元数点积 | 加符号对齐操作 |
| 某轴向误差大 | 该方向纹理重复 | 可视化误差分布 | 增加参考图像密度 |
| 推理速度慢 | 数据库遍历 | 计时各阶段 | 加粗检索+FAISS |
| 跨场景精度差 | 过拟合单场景 | 测试新场景 | 联合训练或冻结骨干 |
避坑技巧:训练初期先冻结几何感知模块,只训骨干和回归头,等损失稳定后再解冻联合训练。这样能避免几何模块在特征还没学好时就引入噪声。
6. 个人实操体会与扩展思路
这套方法我前后复现过三次,每次都有新的坑。第一次跑通是在一个室内办公室场景,大概200平米,参考图像每隔0.5米采一张,总共800张左右。训练用了两块1080Ti,batch size 16,跑了60个epoch,最终位置误差中位数在0.12米,姿态误差中位数在3.5度左右。这个精度对于AR锚点持久化已经够用了,但离论文里报告的最优结果还有差距,主要差在参考图像密度和训练数据量上。
第二次换到一个更大的场景,是一栋三层楼的走廊,参考图像3000多张。这次遇到了严重的重复纹理问题,走廊两侧的白墙几乎一模一样,网络经常把查询图像定位到错误的楼层。后来在几何感知模块里加了一个楼层分类的辅助损失,强制网络学习楼层级别的特征,才把错误率降下来。这个技巧论文里没提,是我自己踩坑踩出来的。
第三次尝试了跨场景泛化,在一个场景训好直接用到另一个场景,精度掉了一半多。后来用冻结骨干+微调回归头的方式,只用了新场景10%的数据就恢复了大部分精度。这说明骨干网络学到的特征确实有一定的通用性,但回归头对场景尺度很敏感,必须微调。
后续如果想继续扩展,我觉得有几个方向值得试:一是把几何感知模块换成Transformer结构,用注意力机制替代相关运算,可能能捕捉更长距离的几何依赖;二是引入时序信息,如果查询是一段视频而不是单张图,可以用相邻帧的位姿变化做平滑约束;三是把相对位姿回归和绝对位姿回归结合起来,用相对回归做精调,用绝对回归做粗定位,两者互补。
最后分享一个小技巧:训练时保存验证集上位置误差最小的模型,而不是训练损失最小的模型。因为训练损失包含负样本的对比损失,跟最终的位姿精度不完全正相关。我试过用训练损失选模型,结果比用验证集位姿误差选模型差了将近20%的精度。这个细节论文里不会写,但实际做项目时很关键。