简介:Mapillary街道级序列(MSLS)是面向长期位置识别与街景视觉定位研究的大规模数据集配套工具包,适合计算机视觉方向的学习者与开发者,用于外观剧烈变化条件下的检索实验。包内共20个文件,以9个Python脚本承担数据加载与工具函数,4个CSV给出im2seq、im2im、seq2im、seq2seq四类检索任务的预测样例,可直接对照提交格式,另有3个Markdown说明文档和一个Jupyter Notebook演示,压缩包为2.21MB,轻量易部署。数据接口既支持返回数据库与查询图像用于评估,也可返回三元组用于训练;独立评测脚本能读取文本预测文件并打印指标,便于统一比较不同主干网络与损失函数的效果。目前已有668人学习,适合需要快速接入MSLS基准、验证外观不变性建模的研究者。
1. 从一段夜间街景说起:这套数据集解决的是"认不出"而不是"看不清"
白天采集的街景库,拿到夜里去检索,画面清晰度其实没掉多少,但召回率会掉得很惨。原因不在图像质量,而在单帧视觉定位本身的天然短板:同一地点在不同时段、不同季节、不同天气下,外观差异可以大到让描述子完全对不上。枫叶街道级序列数据集(SLS)就是针对这个场景构建的:它收集的是按时间连续拍摄的街道图像序列,每张图都带序列 ID、城市 ID、经纬度和拍摄时间,用来训练和评测「同一地点、不同外观」条件下的图像检索与视觉定位模型。
适合谁:正在做视觉位置识别(VPR)、长期定位、地图重定位、SLAM 回环检测的开发者;想找一个规模够大、划分规范、能直接跑 baseline 的公开数据集做对比实验的人;以及需要一套现成的训练/评测脚手架,不想从零写 dataloader 和 Recall@N 的人。整套代码包是 Python + PyTorch 路线,附带 Jupyter Notebook 做数据探查,属于「下载下来改几个路径就能跑」的类型。
2. 目录与元数据拆解:三层目录结构、CSV 字段与序列切分
拿到压缩包先别急着解压到训练脚本同目录,这套数据集的目录层级比一般分类数据集深一层,路径拼错是最常见的低级翻车。拆开看清楚结构,后面 80% 的报错都能自己定位。
2.1 解压后先认清三层目录
解压完的根目录下一般有三个 split 目录,加上一个元数据目录,形态大致如下:
mapillary_sls/ ├── train/ # 训练集,按 城市/序列/图片 三层存放 │ └── <city_id>/ │ └── <sequence_id>/ │ ├── <key>.jpg │ └── ... ├── val/ # 验证集,结构与 train 一致 ├── test/ # 测试集,结构同上 └── postprocessed_csv/ # 元数据,训练和评测全靠它 ├── train.csv ├── val.csv └── test.csv关键点是图片的物理路径不是记录在 CSV 里的完整路径,而是由split / city_id / sequence_id / key.jpg拼出来的。所以你的 Dataset 里一定会有类似这样的拼接逻辑:
import os def build_image_path(root, split, city_id, sequence_id, key, ext=".jpg"): # key 在 csv 里通常不带扩展名,需要自己补 return os.path.join(root, split, str(city_id), str(sequence_id), f"{key}{ext}")这个函数看着简单,但city_id在 CSV 里可能读成int64(比如123),而磁盘上的目录名可能是零填充的字符串(比如00123),中间少一次zfill就会导致「文件全部找不到」。我一般会在 Dataset 初始化时先做一次全量路径存在性检查,把缺失数量打出来,而不是等训练跑起来看 loss 不动才回头查。
序列这一层是这套数据集区别于普通 VPR 数据集的地方。一个sequence_id对应一条连续采集轨迹,序列内的图在时间上相邻、地理上接近。这个结构决定了后面所有的采样策略和评测协议——你如果把它当成一堆无关联的单图来处理,等于把这个数据集最有价值的部分扔了。
2.2 postprocessed_csv 里的字段怎么用
CSV 的表头以你手上这份为准,常见字段和用途如下:
| 字段 | 含义 | 在代码里干什么 |
|---|---|---|
key | 图片唯一标识 | 拼路径、对齐预测结果与真值 |
sequence_id | 序列 ID | 构造正负样本、排除同序列干扰 |
city_id | 城市 ID | 训练/测试城市隔离,防止地理泄漏 |
longitude/latitude | 经纬度 | 计算真值距离、可视化、做坐标回归 |
captured_at | 拍摄时间戳 | 统计昼夜/季节分布,做条件分组评测 |
cluster | 地理聚类 ID | 近似地点分组,避免负样本其实是同一个地方 |
cluster这个字段容易被忽略,但很实用。街景采集车会在同一个路口来回跑好几趟,产生多个sequence_id,它们的经纬度几乎重合。如果你把 A 序列的图当 query、B 序列同路口的图当负样本,模型会被强行推开两个本该靠近的描述子——这就是典型的假负样本。用cluster在采样阶段做一次去重,能明显减少这种噪声。
加载侧我习惯在读进来之后立刻做几件事:
import pandas as pd df = pd.read_csv("postprocessed_csv/train.csv", dtype={"key": str}) # 1) 统计每个城市的序列数和图片数,确认没有空城市 print(df.groupby("city_id").agg(n_img=("key", "size"), n_seq=("sequence_id", "nunique"))) # 2) 时间分布,判断昼夜样本是否严重不均衡 df["captured_at"] = pd.to_datetime(df["captured_at"], unit="ms", errors="coerce") print(df["captured_at"].dt.hour.value_counts().sort_index()) # 3) 检查 key 是否有重复,重复会导致评测时索引错乱 print("duplicated keys:", df["key"].duplicated().sum())这三行统计不是走过场。昼夜样本如果 95% 都是白天,那你训出来的模型在夜间 query 上必然崩,得靠采样权重或者数据增强去补;key重复则会让检索结果里的 top-1 和 top-5 指向同一张图,Recall 指标直接失真。
2.3 序列采样:为什么不能按单张图随机采样
最省事的做法是random.shuffle之后按 batch 取图,但在这套数据上这么干,triplet loss 基本学不动。原因是随机采出来的一个 batch 里,正样本对(同地点不同外观)出现的概率极低,负样本倒是随手一大把,模型很快学会把所有描述子挤到同一个方向。
合格开发普遍用的是PK 采样:每个 batch 选 P 个地点(place),每个地点取 K 张图,batch size = P×K。这里的「地点」可以是cluster,也可以是sequence_id,取决于你的任务定义。实现上不用自己写 Sampler,用DataLoader的batch_sampler参数塞一个自定义采样器就行:
import numpy as np from torch.utils.data import Sampler class PKSampler(Sampler): def __init__(self, df, P=8, K=4, seed=0): self.P, self.K = P, K self.rng = np.random.default_rng(seed) # 按地点分组,得到 {place: [行号, ...]} self.groups = df.groupby("cluster").indices # 图数少于 K 的地点没法凑满,直接剔除 self.places = [p for p, idx in self.groups.items() if len(idx) >= K] self.n_batch = len(self.places) // P def __iter__(self): for _ in range(self.n_batch): picked = self.rng.choice(self.places, self.P, replace=False) batch = [] for p in picked: idx = self.groups[p] # 不加 replace=False 会在小地点上抛异常 batch.extend(self.rng.choice(idx, self.K, replace=len(idx) < self.K).tolist()) yield batch def __len__(self): return self.n_batchP和K的取值直接影响收敛:P 太小负样本不够多样,K 太小正样本对不够稳定。8×4 是个稳妥的起点,显存吃紧就降到 6×4 或 8×3。注意replace那个分支——街道序列里短序列不少,某个cluster只有 2 张图时,replace=False取 4 张会直接抛ValueError,这个坑我在第一次跑的时候就踩了,报错信息还藏在 worker 进程里,不太好看出来。
3. 基线网络拆解:ResNet 主干 + GeM 池化的描述子链路
这套代码包给的 baseline 是标准的「主干 + 池化 + 归一化」三段式检索网络,没有花哨的结构。理解了这条链路,你换主干、换损失、加时序模块都有地方下手。
3.1 从 ResNet 主干到单位描述子
前向链路很短:图像进 ResNet,砍掉最后的全局池化和全连接,得到(B, C, H/32, W/32)的特征图;接一个 GeM 池化压成(B, C);再过一个线性投影到目标维度;最后 L2 归一化。
import torch import torch.nn as nn import torch.nn.functional as F from torchvision import models class GeM(nn.Module): """广义均值池化:p=1 退化成平均池化,p->inf 退化成最大池化""" def __init__(self, p=3.0, eps=1e-6): super().__init__() self.p = nn.Parameter(torch.ones(1) * p) # p 可学习 self.eps = eps def forward(self, x): # x: (B, C, H, W),clamp 防止 0 的负数次幂 x = x.clamp(min=self.eps).pow(self.p) x = F.avg_pool2d(x, (x.size(-2), x.size(-1))) return x.pow(1.0 / self.p) class SLSRetrievalNet(nn.Module): def __init__(self, backbone="resnet50", pretrained=True, desc_dim=2048): super().__init__() net = getattr(models, backbone)(weights="DEFAULT" if pretrained else None) # 去掉 avgpool 和 fc,保留到最后一个 stage 的卷积特征 self.backbone = nn.Sequential(*list(net.children())[:-2]) in_ch = net.fc.in_features self.pool = GeM() self.proj = nn.Linear(in_ch, desc_dim, bias=False) def forward(self, x): f = self.backbone(x) # (B, 2048, H/32, W/32) g = self.pool(f).flatten(1) # (B, 2048) d = self.proj(g) # (B, desc_dim) # 归一化后,内积等价于余弦相似度,检索时直接矩阵乘 return F.normalize(d, p=2, dim=1)几个参数值得说清楚。desc_dim控制描述子维度,2048 是不压缩的默认值;如果后面要上 Faiss 做百万级索引,降到 512 或 256 能省 4~8 倍内存,代价是召回掉一两个点,做消融时要固定住别动。GeM的p初始值设 3.0,训练中会自己往合适的方向走,我一般不冻结它——冻结之后在街景这种纹理密集的图上,池化会偏向平均,显著性区域被稀释。
nn.Sequential(*list(net.children())[:-2])这一句是黑匣子最容易出问题的地方:net.children()在不同 torchvision 版本里层级不一致,有些版本resnet50的 children 最后一层是fc,去掉两层刚好;有些自定义封装里会多包一层。保险做法是打印len(list(net.children())),或者干脆显式写nn.Sequential(net.conv1, net.bn1, net.relu, net.maxpool, net.layer1, net.layer2, net.layer3, net.layer4),版本无关,不会因为升级 torchvision 就静默换结构。
3.2 损失函数与正负样本构造
baseline 用的是度量学习那套:batch 内构造 (anchor, positive, negative) 三元组,让正样本对距离小于负样本对。代码里通常会提供 margin-based 的 triplet 或 contrastive 两种,实际用的时候,batch-hard 挖掘比随机三元组稳得多:
def batch_hard_triplet(desc, labels, margin=0.3): # desc: (B, D) 已 L2 归一化;labels: (B,) 地点/序列标签 sim = desc @ desc.t() # 余弦相似度矩阵 dist = 1.0 - sim # 转成距离 eye = torch.eye(len(desc), dtype=torch.bool, device=desc.device) same = (labels[:, None] == labels[None, :]) & ~eye # 每个 anchor 取最远的正样本、最近的负样本 hardest_pos = (dist * same).max(dim=1).values hardest_neg = (dist.masked_fill(same | eye, float("inf"))).min(dim=1).values loss = F.relu(hardest_pos - hardest_neg + margin) # 没有正样本的 anchor 会产生 0 梯度,这里要滤掉 valid = same.any(dim=1) return loss[valid].mean() if valid.any() else desc.sum() * 0.0margin取 0.2~0.4 之间比较常见,太大前期训不动,太小描述子挤在一起区分度不够。valid那行别省:某些 batch 里如果某个地点的图全被采样器丢了,会出现没有正样本的 anchor,max返回 0,loss 被稀释,看起来在降其实没学。返回desc.sum() * 0.0而不是0.0,是为了保住计算图,防止 DDP 或多卡下报「梯度未参与计算」的错。
标签用什么取决于评测协议。做「同序列召回」就用sequence_id当标签,做「同地点召回」就用cluster。这两者训出来的模型行为差别不小,混着用会导致指标对不上论文里的数字。
3.3 训练脚本的参数面
训练入口一般长这样,参数名以--help实际输出为准,含义是通用的:
python train.py \ --backbone resnet50 \ --desc_dim 2048 \ --image_size 320 320 \ --batch_size 32 \ --lr 1e-4 \ --epochs 30 \ --margin 0.3 \ --train_csv postprocessed_csv/train.csv \ --val_csv postprocessed_csv/val.csv \ --root ./mapillary_sls \ --pretrained \ --workers 8--lr 1e-4是带预训练主干时的常见起点,如果主干冻结只训投影层,可以拉到 1e-3。--image_size别乱调,街景图普遍是宽幅,直接320 320会把人行道和天空压扁,长宽比失真之后 GeM 的显著性分布会变,我一般按长边 resize 再中心裁剪,代码里对应Resize(384) + CenterCrop(320)。--workers在机械硬盘上开到 8 以上反而慢,因为随机读大量小文件会排队,SSD 上 8~12 比较合适。
4. 跑通流程:环境准备、冒烟测试、训练与 Recall@N 评测
到这一步假设数据已经解压好、CSV 也在位。整套流程我建议按「环境 → 路径校验 → 单 batch 过拟合 → 全量训练 → 评测」的顺序走,每一步都能独立失败,定位成本最低。
4.1 环境和依赖
代码包是纯 PyTorch 路线,Notebook 用来做数据探查和结果可视化,不参与训练。依赖不多:
conda create -n sls python=3.10 -y conda activate sls # 版本按你本地 CUDA 对齐,这里给的是通用组合 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install pandas numpy pillow tqdm scikit-learn matplotlib jupyterscikit-learn和matplotlib主要是给 Notebook 画分布图、算 t-SNE 用的。如果只是跑训练,其实用不到。真正要确认的是torch.cuda.is_available()返回 True,以及 torchvision 的版本和主干权重下载能不能通——很多「卡在第一个 epoch 不动」的问题,其实是预训练权重下载超时,程序在等网络。
4.2 先做一次小样本冒烟测试
我现在的习惯是:全量训练之前,先拿 200 张图、10 个地点,跑 50 个 step,看 loss 能不能掉到接近 0。能过拟合,说明数据管道、标签、损失、反传这条链路是通的;掉不下去,问题一定在数据侧,跟模型结构没关系。
from torch.utils.data import DataLoader # 只取一小撮数据,构造一个一定能过拟合的场景 small = df[df["cluster"].isin(df["cluster"].unique()[:10])].head(200).reset_index(drop=True) ds = SLSDataset(small, root="./mapillary_sls", split="train", image_size=224) loader = DataLoader(ds, batch_size=20, shuffle=True, num_workers=0) # 调试期关掉 worker model = SLSRetrievalNet(pretrained=True).cuda() opt = torch.optim.AdamW(model.parameters(), lr=1e-4) for step, (imgs, labels) in enumerate(loader): imgs, labels = imgs.cuda(), labels.cuda() desc = model(imgs) loss = batch_hard_triplet(desc, labels) opt.zero_grad(); loss.backward(); opt.step() print(step, round(loss.item(), 4)) if step >= 50: breaknum_workers=0是调试期的硬性要求:worker 进程里的异常栈信息会被吞掉,只留一句DataLoader worker (pid xxx) is killed by signal,查起来极其难受。等确认能过拟合了,再开多 worker 提速。loss 应该在前 20 步内明显下降,如果一直在 0.3 附近抖动,八成是负样本里混了同一地点,或者正负标签搞反了。
顺便在这个阶段把imgs反归一化存一张出来看一眼。血泪经验:有一次我把 RGB 通道顺序搞成了 BGR,训练完全正常,loss 也在降,但评测指标比 baseline 低了七八个点,查了两天才发现是读图库的默认行为不一致。
4.3 评测:Recall@N 与序列干扰排除
评测阶段的核心是把 query 和 gallery 的描述子算出来,做一次相似度矩阵,然后按行排序取 top-N,看真值有没有落在里面。SLS 的评测有个额外要求:要区分「同序列」和「跨序列」两种召回,因为同序列的相邻帧外观几乎一样,检索难度接近白送,只看这个数字会严重高估模型。
@torch.no_grad() def evaluate(model, q_loader, g_loader, topk=(1, 5, 10)): model.eval() q_desc, q_seq = [], [] for imgs, seqs in q_loader: q_desc.append(model(imgs.cuda()).cpu()) q_seq.append(seqs) q_desc, q_seq = torch.cat(q_desc), torch.cat(q_seq) g_desc, g_seq = [], [] for imgs, seqs in g_loader: g_desc.append(model(imgs.cuda()).cpu()) g_seq.append(seqs) g_desc, g_seq = torch.cat(g_desc), torch.cat(g_seq) sim = q_desc @ g_desc.t() # (Nq, Ng),已归一化,等价余弦 # 排除同序列的 gallery 项:把它们压到极小值 same_seq = q_seq[:, None] == g_seq[None, :] sim = sim.masked_fill(same_seq, -1.0) ranks = sim.argsort(dim=1, descending=True) # 这里假设 gallery 已按 query 对齐(1:1 时直接取对角), # 非 1:1 场景需要按 cluster / 坐标匹配真值,逻辑要另写 gt = torch.arange(len(q_desc)) pos = (ranks == gt[:, None]).float().argmax(dim=1) return {f"R@{k}": float((pos < k).float().mean()) for k in topk}几个细节。masked_fill(same_seq, -1.0)这一步是这套数据集评测的关键,不做的话 R@1 会被同序列邻居虚抬十几个点,跟别人对比时完全没有可比性。argsort在百万级 gallery 上内存吃不消,实际工程里换成 Faiss 的IndexFlatIP分批检索更靠谱。另外如果 query 和 gallery 不是一一对应,gt = arange这个假设就不成立,需要按经纬度距离阈值(比如 25 米内算命中)来判真值,这是另一套写法,改之前先确认自己用的是哪种协议。
指标跑出来之后,别只看总数。按captured_at的小时数分组统计一遍,白天/夜间分开看 R@1,差距超过 20 个点就说明模型严重依赖光照条件,后面二次开发的方向就明确了。
5. 避坑与排查:五个把实验跑废的细节
5.1 训练 loss 正常但 R@1 只有个位数
现象:训练 loss 稳定下降,验证 loss 也好看,但评测 R@1 只有 3~8,跟随机猜差不多。原因:八成是评测时 query 和 gallery 的描述子没做同一套归一化,或者一边用了model.eval()一边忘了切回 eval 模式导致 BN 统计量在跑。另一种常见情况是 gallery 集里混进了 query 自身的近重复帧,排序被这些帧占满。解决:在evaluate开头显式model.eval(),并在算完描述子后打印一下模长(应该全是 1.0)。再检查 gallery 里是否有与 query 同sequence_id的项没被 mask 掉。
5.2 DataLoader 报错 "string indices must be integers"
现象:Dataset 写好了,一迭代就抛类型错误,栈信息指向 CSV 取值那一行。原因:df.iterrows()返回的是(index, Series),如果你写for row in df.iterrows(): row["key"],拿到的是元组不是行。另外 CSV 里city_id被 pandas 推断成int64,和磁盘上零填充的目录名对不上,路径拼接后os.path.exists全 False,程序在后续读取时才炸。解决:改用df.to_dict("records")或df.iloc[i];读 CSV 时显式指定dtype={"key": str, "city_id": str},并在__init__里跑一遍路径存在性统计,把缺失数量在启动时就打出来。
5.3 多卡训练后指标比单卡还低
现象:单卡 R@1 是 62,换成两卡 DDP 之后掉到 55 左右,学习率没改。原因:DDP 下每个进程各自跑一遍PKSampler,随机种子相同的话会采到完全一样的 batch,等于 batch size 没变但梯度做了平均;种子不同的话,每个进程内 batch 只有原来一半,batch-hard 挖掘能碰到的最难负样本变少了。解决:把batch_size按卡数放大,同步调整lr(线性缩放或 sqrt 缩放都行,先小步试),并给每个 rank 传不同 seed 的 Sampler。改完先跑 3 个 epoch 看曲线斜率对不对,别一上来就跑满。
5.4 显存明明够,却在第 2 个 epoch OOM
现象:第一个 epoch 正常,第二个 epoch 开始显存缓慢上涨,最后 OOM。原因:验证阶段忘了torch.no_grad(),或者把整个 gallery 的描述子堆在一个 list 里最后一次性torch.cat,gallery 大的时候直接把显存吃干净。另一种可能是日志里把 loss 张量存进了 list 而没.item(),计算图一直被引用,释放不掉。解决:验证循环套@torch.no_grad(),描述子算完立刻.cpu();日志统一loss.item();gallery 规模超过十万时改成写入磁盘或内存映射,别全塞显存。
5.5 指标异常高,换一批测试数据就崩
现象:自己的测试集上 R@1 有 85,换一个城市的数据直接掉到 30。原因:训练集和测试集在城市粒度上没做隔离。街景采集有很强的城市风格特征——路牌样式、建筑材质、植被颜色,模型会把这些当捷径学进去。同城划分的话,测试集里的地点在训练时被「间接见过」。解决:严格按city_id划 train/val/test,验证集也最好用没见过的城市。训练时如果发现验证指标远高于跨城市测试指标,说明模型在记城市风格而不是地点特征,得加颜色抖动、灰度化、随机模糊这类外观增强,把捷径堵掉。
6. 二次开发:换主干、加时序聚合、接自有数据的三条改造路线
这份代码包的价值不在于 baseline 本身跑多高,而在于它的数据管道和评测协议是干净的,你可以在上面做对比实验而不被脚手架拖累。三条改造路线按投入从低到高排:
换主干。SLSRetrievalNet里主干是独立的一层,换成 ConvNeXt、ViT、DINOv2 都不需要动评测代码,只要保证输出是(B, C, H, W)的特征图就行。ViT 这类没有空间维度的结构要特殊处理——把 patch token reshape 回二维网格再进 GeM,或者直接换 attention pooling(一个可学习的 query 向量对 token 做加权求和)。换完先别急着全量训,用 4.2 的过拟合测试确认梯度能走通,再跑 5 个 epoch 看验证曲线。
加时序聚合。这是这套数据集最值得做的方向:既然一个sequence_id里有连续多帧,那 query 可以是「一段序列」而不是「一张图」。最简单的做法是取序列内相邻 T 帧的描述子做平均或 VLAD 编码,再和单帧 gallery 比;进阶一点用一个小的 Transformer encoder 在时间维做 self-attention,输出一个序列级描述子。改造点是 Dataset 返回(T, 3, H, W)的片段、模型 forward 多一个时间维、评测时 query 侧的 batch 组织方式要跟着改。这里有个容易忽略的点:序列内帧间隔要固定(比如每隔 1 帧取一帧),否则不同长度的序列聚合出来的描述子尺度不一致。
接自有数据。把自己的采集数据转成这套格式,其实只要凑齐三样东西:city_id/sequence_id/key.jpg的目录结构、一份带key/sequence_id/cluster/经纬度的 CSV、以及按城市划分的 split 策略。转换脚本我自己写过一个最小版:
import pandas as pd, pathlib, hashlib rows = [] for seq_dir in sorted(pathlib.Path("raw_data").iterdir()): if not seq_dir.is_dir(): continue seq_id = hashlib.md5(seq_dir.name.encode()).hexdigest()[:12] # 稳定 ID,别用自增 for img in sorted(seq_dir.glob("*.jpg")): rows.append({ "key": img.stem, "sequence_id": seq_id, "city_id": "c001", "cluster": seq_id, # 没有地理聚类时退化成序列级 "longitude": 0.0, "latitude": 0.0, }) pd.DataFrame(rows).to_csv("postprocessed_csv/train.csv", index=False)sequence_id用哈希而不是自增,是为了多次转换时 ID 稳定——自增 ID 一旦图片顺序变了,之前训好的模型和缓存全废。cluster在自有数据上通常没有现成的,先用sequence_id顶着,代价是同一个路口的多次采集会被当成不同地点,假负样本变多,指标会偏低,但至少不会虚高。
验证改造有没有效果,我固定用三件事对照:小样本过拟合能不能到 0、跨城市测试集上 R@1 有没有提升、以及按昼夜分组后差距有没有缩小。前两个看方向对不对,第三个看模型是不是还在吃光照捷径。从那以后我每次动主干或者加模块,都强制先跑一遍小样本过拟合再上全量——省下的不是时间,是那种「训了三天发现标签错了」的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取