枫叶街道级序列数据集SLS街景重定位全流程实战

发布时间:2026/11/5 4:57:33
枫叶街道级序列数据集SLS街景重定位全流程实战 简介Mapillary街道级序列MSLS配套工程包面向计算机视觉中的长期位置识别研究与开发场景围绕约160万张街道级图像的大规模数据集提供可直接接入的训练与评测代码帮助读者处理跨时间、跨视角的外观变化问题。包内共20个文件以9个Python脚本为核心配合4个CSV预测示例、3个Markdown说明、1个Jupyter Notebook演示及依赖与许可文件压缩包约2.21MB结构紧凑便于快速浏览数据接口与评测流程。已有668人学习。资料包含PyTorch数据接口可在评估时返回数据库与查询图像在训练时返回三元组独立评估脚本能读取文本预测并输出指标示例覆盖图像到序列、图像到图像、序列到图像、序列到序列等任务形态演示笔记本展示调用方式并附补丁说明修复部分损坏图像。适合需要复现ResNet50广义均值层、三元组损失或贝叶斯三元组损失实验的读者参考。1. 枫叶街道级序列数据集一条街的四季与昼夜都压成同一个地点标签同一个街角一月积雪盖住路沿七月树荫吃掉招牌夜里只剩路灯。人一眼能认出是同一条街模型行不行枫叶街道级序列数据集下称 SLS就是拿这类问题当主要内容的计算机视觉数据集按城市、序列、图像三级组织一条序列里连续拍摄的几百帧共享地点标签跨季节、跨昼夜、跨年份的外观不变性appearance-invariance全压在同一组 place 上。它服务的是街景召回、视觉重定位、建图回环检测而非分类练手。如果你正被「换个天气模型就崩」折磨或者想找一个长尾分布、真实拍摄条件的街景数据集压测 backbone这套东西比玩具集有用得多。我拿到手第一件事是在 JupyterNotebook 里把城市分布和序列长度画了一遍才敢动训练代码。2. 数据组织与加载链路从 city/sequence 目录到 batch 张量先把包解开看一眼别急着写模型。SLS 的数据量决定了你不可能一次性读进内存所以整个工程的第一道关卡是「索引」——把百万级图片的相对路径、地点标签、采集元信息整理成能随机访问的结构。这一层做对了后面采样、评测、可视化都是顺手的事做错了你会在训练到第三十个 epoch 时发现正样本对根本不在同一个 place 上。2.1 目录结构与索引文件长什么样解压后的典型布局是这样imgs放像素dataset放纯文本清单sls/ ├── imgs/ │ ├── train/ │ │ └── train_city_a/ │ │ └── seq_0007/ │ │ ├── 2019-04-11_09-15-02.jpg │ │ └── 2019-04-11_09-15-05.jpg │ ├── val/ │ └── test/ ├── dataset/ │ ├── train_list.txt │ ├── val_query.txt │ ├── val_database.txt │ ├── test_query.txt │ ├── test_database.txt │ └── metadata.json └── code/ ├── dataset.py ├── model.py ├── train.py └── evaluate.py清单文件一行一条记录相对imgs根目录的路径train/train_city_a/seq_0007/2019-04-11_09-15-02.jpg train/train_city_a/seq_0007/2019-04-11_09-15-05.jpg train/train_city_b/seq_0031/2018-11-02_16-40-11.jpgmetadata.json以同一个 key 为索引存的是几何和时间信息。字段大致如下实际项目里字段名可能略有出入读取时先打印一条看看再写死字段含义加载时的用途key与清单行一致的相对路径关联清单和元数据seq_id序列标识同一段行走的多帧共享评测时做序列聚合city_id城市代号划分训练/验证、做跨城市泛化实验lat/lon拍摄点经纬度量化成 place 网格构造正负样本captured_atUTC 时间戳按时间排序还原序列顺序heading相机朝向角单位度分析视角变化对召回的影响height相机离地高度少数高空/车载视角的筛除依据这里的place概念要自己造。图像本身没有「地点」标签只有坐标所以常见做法是用经纬度量化按大约 100 米精度截断小数位得到(round(lat,3), round(lon,3))作为粗粒度 place。粒度太粗负样本会变成同一条街的邻帧学不到判别力太细正样本对几乎凑不齐。2.2 数据集类的实现把正负样本构造写进__getitem__最省事的写法是把三元组构造直接塞进 Dataset。适合快速验证代码短调试直观import json import random from pathlib import Path import torch from PIL import Image from torch.utils.data import Dataset class SLSPlaceDataset(Dataset): 按 place 网格组织街景序列每次返回 (query, positive, negatives)。 def __init__(self, root, list_file, meta_file, grid3, neg_per_pos2, transformNone): self.root Path(root) self.transform transform self.neg_per_pos neg_per_pos raw [ln.strip() for ln in open(list_file) if ln.strip()] with open(meta_file) as f: meta json.load(f) # 1) 用经纬度量化出 place作为训练时的类别中心 self.place2imgs {} for key in raw: m meta[key] place (round(m[lat], grid), round(m[lon], grid)) self.place2imgs.setdefault(place, []).append(key) # 2) 少于两张图的 place 无法构成正样本对直接丢弃 self.places [p for p, v in self.place2imgs.items() if len(v) 2] self.place2imgs {p: self.place2imgs[p] for p in self.places} def __len__(self): return len(self.places) def _load(self, key): img Image.open(self.root / imgs / key).convert(RGB) return self.transform(img) if self.transform else img def __getitem__(self, idx): place self.places[idx] # 正样本同一 place 下的两张不同帧跨季节/昼夜的概率很高 q_key, p_key random.sample(self.place2imgs[place], 2) # 负样本从其余 place 里随机挑避免同城市相邻街区被误当负例 neg_places random.sample( [p for p in self.places if p ! place], min(self.neg_per_pos, len(self.places) - 1), ) n_keys [random.choice(self.place2imgs[p]) for p in neg_places] return { query: self._load(q_key), positive: self._load(p_key), negatives: torch.stack([self._load(k) for k in n_keys]), key: q_key, place: torch.tensor(hash(place) % (10 ** 8)), }逻辑说明grid控制量化精度3 位小数约 100 米想更严格就设成 4neg_per_pos是每个正样本配几个负例配合三元组损失时一般设 2 到 4。self.places过滤掉了单帧 place这一步不做的话random.sample会直接抛ValueError: Sample larger than population第一次跑必踩。还有个小细节place用哈希值返回是为了让 collate 能堆成张量但它不是稳定的类别 ID只用于训练时打日志。要长期追踪某个 place 的表现老老实实另存一份place_id - 坐标的映射表。2.3 换成 PK 采样器为什么正式训练不建议用上面的写法上面那种「一个样本就是一个完整三元组」的写法有个隐蔽问题batch 内的负样本不共享。BN 统计量在每张卡上算如果 batch 里不同 place 的分布差异极大损失波动会很难看。工程上更常见的是 Dataset 只返回单张图把组批逻辑交给BatchSampler实现 P 个地点 × K 张图的 PK 采样import random from torch.utils.data import Sampler class PKBatchSampler(Sampler): 每批 P 个 place每个 place 采 K 张图保证批内有充足正负例。 def __init__(self, place2idx, P4, K4, batches_per_epoch500, seed0): self.place2idx place2idx self.place_list list(place2idx.keys()) self.P, self.K P, K self.batches_per_epoch batches_per_epoch self.seed seed self.epoch 0 def set_epoch(self, epoch): self.epoch epoch def __len__(self): return self.batches_per_epoch def __iter__(self): rng random.Random(self.seed self.epoch) # 每个 epoch 换种子 for _ in range(self.batches_per_epoch): places rng.sample(self.place_list, self.P) batch [] for p in places: idxs self.place2idx[p] # 有放回采样应对某些 place 图像数少于 K 的情况 batch.extend(rng.choices(idxs, kself.K)) yield batch参数说明P*K就是实际 batch sizeP4、K4 是 16单卡 16G 显存跑 320×320 输入基本够用batches_per_epoch是虚拟 epoch 长度跟数据集大小解耦便于控制单次实验时长。注意set_epoch必须在每个 epoch 开头调用一次否则每个 epoch 的采样顺序完全一样数据增强的随机性被吃掉了指标曲线会出现诡异的周期性尖峰——这个坑我踩过一次排查了两个晚上。换成这个结构后Dataset 的__getitem__只负责读图和 transform返回(img, place_id, key)三元组即可正负样本的配对交给损失函数在批内用距离矩阵完成。第 3 章会接上。3. 模型与损失外观不变性靠什么学出来数据侧解决的是「喂什么」模型侧解决的是「怎么把同一地点不同外观的图拉到一起」。街景重定位这个任务分类头基本没有意义——测试时的地点根本不在训练集里。所以模型必须输出一个度量空间里的向量用距离说话。这一章把骨干改造、池化、损失三段拆开。3.1 骨干网络与 GeM 池化ResNet50 是这条线上的默认起点改动集中在两处去掉全连接分类头把全局平均池化换成广义平均池化GeM。平均池化对显著区域一视同仁而街景里真正有判别力往往只是招牌、门窗、路牌那一小块GeM 通过可学习的指数 p 放大高响应区域import torch import torch.nn as nn import torch.nn.functional as F import torchvision class GeM(nn.Module): 广义平均池化p1 退化为平均池化p 越大越接近最大池化。 def __init__(self, p3.0, eps1e-6): super().__init__() self.p nn.Parameter(torch.ones(1) * p) # p 参与训练 self.eps eps def forward(self, x): return F.avg_pool2d( x.clamp(minself.eps).pow(self.p), (x.size(-2), x.size(-1)), ).pow(1.0 / self.p) class SLSRetrievalNet(nn.Module): def __init__(self, archresnet50, dim512, pretrainedTrue): super().__init__() weights IMAGENET1K_V2 if pretrained else None backbone getattr(torchvision.models, arch)(weightsweights) # 只保留到最后一个 stage去掉 avgpool 和 fc self.body nn.Sequential(*list(backbone.children())[:-2]) in_ch backbone.fc.in_features self.pool GeM() self.proj nn.Linear(in_ch, dim, biasFalse) # 降维省显存也省检索时间 def forward(self, x): f self.body(x) # [B, 2048, H/32, W/32] f self.pool(f).flatten(1) # [B, 2048] f self.proj(f) # [B, dim] return F.normalize(f, dim1) # 单位球面内积即余弦相似度dim默认 512显存紧张可以压到 128检索时特征矩阵直接从N x 512变成N x 128百万级底库的内存占用差出一个量级。F.normalize放在 forward 里是有意为之后面所有的距离计算都退化成矩阵乘法省掉一次cdist。池化层之后不要接 BNGeM 输出恒正BN 会把它拉出单位球面的假设。3.2 批内困难三元组损失有了 PK 采样一个 batch 里天然存在正负对直接从距离矩阵里挖最难的样本就行。相比离线挖矿批内挖矿不用维护样本队列代价是难度受 batch 组成影响def batch_hard_triplet_loss(emb, labels, margin0.3): emb 已 L2 归一化返回标量损失。 dist torch.cdist(emb, emb) # [B, B] 欧氏距离 same labels[:, None] labels[None, :] eye torch.eye(len(labels), dtypetorch.bool, deviceemb.device) pos_mask same ~eye # 排除自身 # 最远正样本 / 最近负样本 pos dist.masked_fill(~pos_mask, -1e4).max(dim1).values neg dist.masked_fill(same, 1e4).min(dim1).values loss F.relu(pos - neg margin) valid pos_mask.any(dim1) # 没有正样本的锚点丢弃 return loss[valid].mean()margin设 0.3 是经验值配合归一化特征最大距离 2.0比较合适设到 1.0 以上早期几乎全部锚点都被判为满足约束梯度接近 0训练会「假收敛」。masked_fill的填充值用 ±1e4 而不是inf是因为mean()遇到inf会直接传播成 NaN这个细节不注意loss 打印出来是nan你还得回头查。想再稳一点可以在这个基础上加一个分类辅助头把 place 当类别做交叉熵多任务训练前期收敛明显快但权重不要给太高0.1 到 0.2 即可否则模型会退化成过拟合训练集城市的分类器跨城市泛化直接崩。3.3 训练循环warmup 与混合精度街景数据集的城市分布极度不均衡几个大城市能占掉三成以上样本。学习率一开始就给大模型会先被大城市主导后期很难纠回来。常见做法是线性 warmup 加余弦退火import math from torch.optim.lr_scheduler import LambdaLR def build_scheduler(optimizer, warmup_epochs5, total_epochs40, base_lr1e-4): def fn(epoch): if epoch warmup_epochs: return (epoch 1) / warmup_epochs # 线性爬升 progress (epoch - warmup_epochs) / max(1, total_epochs - warmup_epochs) return 0.5 * (1 math.cos(math.pi * progress)) # 余弦衰减 return LambdaLR(optimizer, fn) scaler torch.cuda.amp.GradScaler() for epoch in range(total_epochs): sampler.set_epoch(epoch) model.train() for imgs, labels, _ in loader: imgs imgs.cuda(non_blockingTrue) labels labels.cuda(non_blockingTrue) optimizer.zero_grad(set_to_noneTrue) with torch.cuda.amp.autocast(): emb model(imgs) loss batch_hard_triplet_loss(emb, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) scaler.step(optimizer) scaler.update() scheduler.step()warmup_epochs按总 epoch 的 10% 到 15% 取40 个 epoch 就设 5clip_grad_norm_的 5.0 是防三元组损失偶发尖峰导致权重炸掉的保险尤其是某个 batch 恰好全是困难样本的时候。混合精度必须配 GradScaler直接autocast不缩放梯度下溢会让模型在几百步之后突然不学了而且没有任何报错纯黑匣子。训练日志至少记三样loss 均值、批内正样本平均距离、批内负样本平均距离。第三个指标如果长期贴着 1.4 以上归一化后的最大距离是 2说明负样本太容易模型没在学判别力只是在学颜色统计。4. 评测流水线四种序列口径下的 RecallN训练跑通只是开始SLS 真正的门槛在评测口径。单图检索和序列检索的分数能差十几个点很多复现实验对不上问题都出在这里。评测分三步抽特征、按口径聚合、算 Recall。4.1 特征提取与磁盘缓存底库动辄几十万张每次评测重跑一遍前向是浪费。我一般把特征和 key 一起落盘fp16 存体积减半import numpy as np import torch torch.no_grad() def extract_features(model, loader, devicecuda, use_ampTrue): model.eval() feats, keys [], [] for batch in loader: imgs batch[img].to(device, non_blockingTrue) with torch.autocast(cuda, dtypetorch.float16, enableduse_amp): f model(imgs) feats.append(f.float().cpu()) keys.extend(batch[key]) F torch.cat(feats, dim0) F torch.nn.functional.normalize(F, dim1) return F, keys def save_cache(path, F, keys, place_ids): np.savez_compressed( path, featF.numpy().astype(np.float16), # 存 fp16读回来再转 fp32 keynp.array(keys), placenp.array(place_ids), )use_amp在评测时也可以开速度能快接近一倍精度损失在 1e-3 量级对 Recall 的排名影响基本看不出来。存 fp16 要注意读回来之后必须做一次normalizefloat16 - float32的转换会带来微小范数偏移不归一化直接算内积第一名和第十名偶尔会互换。place_ids单独存一份很有必要。Recall 的命中判定是「检索结果里有没有和 query 同 place 的图」没有 place 映射就得回查元数据几十万次查询能把评测脚本拖到十分钟以上。4.2 RecallN 与序列聚合四种评测口径区别只在 query 侧和数据库侧是不是序列口径query 单位数据库单位聚合方式im2im单图单图直接取 topkseq2im同序列多帧单图序列内各帧对候选取最大相似度im2seq单图序列候选序列内各帧对 query 取最大相似度seq2seq同序列多帧序列双向 max 之后再取 topk实际写的时候不用真的构造「序列特征向量」取平均会把遮挡帧的噪声混进去。更稳的做法是把序列展开成帧在相似度矩阵上做 max 归约def recall_at_n(sim, q_place, db_place, q_seq, N(1, 5, 10, 20)): sim: [Q, D] 余弦相似度矩阵q_seq: 每条 query 所属序列 ID。 results {} order sim.argsort(dim1, descendingTrue) # 每行按相似度降序 for n in N: topk order[:, :n] # 命中topk 里任一候选与 query 同 place hit (db_place[topk] q_place[:, None]).any(dim1) results[fR{n}] hit.float().mean().item() # seq2im 口径同一 query 序列的多帧结果取并集 seq_first {} for i, s in enumerate(q_seq): seq_first.setdefault(s, i) return results def seq_aggregate(sim, q_seq): 把同序列的 query 帧合并对每个候选取序列内最大相似度。 out, owners [], [] for s in dict.fromkeys(q_seq): rows [i for i, x in enumerate(q_seq) if x s] out.append(sim[rows].max(dim0).values) owners.append(s) return torch.stack(out), ownersseq_aggregate里dict.fromkeys保证序列顺序稳定别用set顺序一变输出的行和 query 序列就对不上了。recall_at_n里的命中判定用 place 相等而不是图像 key 相等——同一地点不同时间的图必然不是同一张用 key 判等会让 Recall 永远是 0。4.3 把评测脚本跑起来参数固定下来之后一条命令走完python evaluate.py \ --root /data/sls \ --split val \ --query-list dataset/val_query.txt \ --db-list dataset/val_database.txt \ --meta dataset/metadata.json \ --ckpt runs/exp01/best.pth \ --arch resnet50 --dim 512 \ --img-size 320 --batch-size 64 \ --cache runs/exp01/val_feat.npz \ --seq-agg max--split val能本地出分--split test在多数情况下只能生成提交文件真正的测试集标注不在包里。这一点很关键本地调参只能在验证集上做反复用验证集选超参最后提交分数腰斩是常态。稳妥做法是把验证集再切一刀八成调参、两成做最终确认。--img-size要和训练时严格一致320 训练 384 评测这种「测试时提升分辨率」的技巧在这条线上收益不稳定且会放大训练与推理的分布差异。--cache指向已存在文件时直接读不重新抽特征跑消融实验时能省掉一半时间。5. 避坑与排查五个让我返工的现场5.1 训练侧的三个坑现象一loss 前两百步掉得飞快然后长期贴在 0.05 附近Recall 一动不动。原因负样本太容易。用城市当 place 或者网格量化得太粗随机采到的负例跟正例隔着几公里模型只要学到「建筑风格」就能拉开距离判别力根本没被逼出来。解决把grid从 2 调到 3 甚至 4让 place 收缩到街区级同时把neg_per_pos提到 4配合批内困难挖掘。改完 loss 会先上升再缓慢下降这是正常的。现象二训练到一半显存 OOM但 batch size 明明和昨天一样。原因序列长度不一致。某些城市的序列动辄几百帧如果 dataloader 里做了「随机取一个序列的全部帧」这类操作单个样本的张量尺寸会剧烈抖动峰值显存被打满。解决固定每个样本只取一帧或固定 4 帧序列信息留到评测阶段再聚合。开了 AMP 之后仍然 OOM就用梯度累积accum_steps4实际 batch 等效放大四倍显存不变。现象三训练集 Recall 能到 0.95验证集只有 0.3。原因验证集里的城市在训练集出现过但采样时把同一序列的相邻帧分到了训练和验证两侧。相邻帧的视角和光照几乎一样模型记住了画面而不是地点。解决按seq_id切分而不是按图像随机切。同一个序列的所有帧必须整体落在同一侧。这个错误在数据准备脚本里最容易犯建议切完立刻写个断言检查两侧seq_id集合的交集为空。5.2 评测侧的两个坑现象四本地验证集跑出 R1 0.62提交上去只有 0.41。原因query 和 database 列表存在重叠。有些整理脚本为了「保证每个 query 都能在底库找到答案」把 query 自身也塞进了 database本地分数虚高。解决评测前先算set(query_keys) set(db_keys)输出交集大小非空就中断。另外还要排除同序列的帧——同一段行走的相邻帧相似度接近 1等于直接给答案。现象五Image.open抛FileNotFoundError但路径肉眼看着完全正确。原因清单文件里的路径分隔符或大小写跟实际解压结果不一致尤其在 Windows 上解压再拷到 Linux 的情况。也可能是清单里混进了空行或 BOM 头。解决写个一次性校验脚本全量遍历清单做Path.exists()检查顺便统计缺失比例from pathlib import Path root Path(/data/sls/imgs) missing [] for ln in open(dataset/val_database.txt, encodingutf-8-sig): key ln.strip() if key and not (root / key).exists(): missing.append(key) print(ftotal missing: {len(missing)}) print(\n.join(missing[:10]))encodingutf-8-sig是必须的带 BOM 的文件第一行会被读成\ufefftrain/...exists()永远返回 False而报错信息里看不出来。这个坑查起来极其浪费时间。6. 二次开发换骨干、加城市、把评测跑成闭环拿到这份资源之后最容易上头的事是直接换个大模型骨干然后全量重训。我建议先压住这个念头。SLS 的调参成本主要不在模型而在数据管线和评测口径先把第 4 章的评测脚本跑通、把基线分数复现出来再动模型否则你连「变好还是变坏」都判断不了。换骨干的话ViT 系比如 DINOv2 的 base 版本在这条线上确实有增益但要注意两点。一是 patch 尺寸决定特征图分辨率patch14、输入 320 时特征图只有 23×23GeM 池化的感受野比 ResNet50 的 10×10 大不少对小区块招牌的响应会变钝通常要把输入提到 448 或改 patch 尺寸。二是 ViT 的位置编码跟输入尺寸绑死直接改分辨率需要插值插值方式不对精度会掉得莫名其妙。改造检查清单改造点预期收益主要风险ResNet50 → ViT-B/14R1 提升 36 点显存翻倍需梯度累积特征维度 512 → 128检索延迟降 4 倍R1 掉 12 点加分类辅助头前期收敛快权重过大会丢泛化加新城市数据跨域泛化变好城市分布进一步失衡加新城市是更实际的需求。流程是固定的先按city_id命名建目录序列目录按采集时间排序重建文件名里的时间戳就是干这个用的然后重算坐标量化网格写进 metadata。序列重建这一步别偷懒如果直接把零散图片丢进同一个seq_xxx目录评测阶段的 seq2im 聚合会变成毫无意义的特征平均分数不升反降。新增城市的数据还有个大坑它们的时间分布、相机高度、分辨率往往和原有数据不一致。我的做法是抽 200 张出来算一下图像尺寸和 EXIF 里的焦距分布跟原有数据对比差异超过 15% 就在预处理里统一 resize 加裁剪别指望模型自己适应。验证改造是否有效我用一个很土但很准的办法先取 8 个 place、每个 place 4 帧组成一个极小数据集关闭所有增强训练到训练集上 R1 达到 1.0。跑不到 1.0 说明模型或损失有 bug跟数据规模无关。这一步通常十几分钟就能完成能省掉好几天的全量训练。等小样本过拟合没问题再上全量第一次只跑 5 个 epoch 看验证集曲线是否单调最后才放开到 40 个 epoch。从那以后我每次接入新城市数据都强制先让 8 个 place 过拟合到 100%再开全量训练宁可多花二十分钟也不愿意再经历一次「训了三天发现标签错位」。完整的目录结构、清单文件和评测脚本都在包里跑之前建议先按第 2 章核对一遍目录缺哪个文件当场就能看出来。希望帮到你。本文还有配套的精品资源点击获取