简介面向视频描述与视频-文本检索方向研究者的 MSR-VTT 10K v2.0 数据划分资源重点解决官方标注文件只给出 video 与 caption 的整体对应关系、需自行切分训练/验证/测试集的问题。压缩包共 2 个文件约 3.09MB含 1 个 JSON 标注文件与 1 个 Python 读写脚本前者沿用原始 videodatainfo 结构完成 train、val、test 的 split其中 train 覆盖 video0 至 video6512 共 6513 条val 为 video6513 至 video7009 共 497 条test 为 video7010 至 video9999 共 2990 条后者演示如何读取、筛选并重新写出 JSON便于按需调整划分比例或接入自己的训练管线。已有 2252 人学习下载适合需要快速搭建可复现实验基线、避免因划分不一致导致指标不可比的研究生与算法工程师参考使用。1. 先别急着解压MSR-VTT 10K v2.0.rar 到底是什么为什么它仍是视频文本检索的硬基线拿到 MSR-VTT 10K v2.0.rar 的人第一反应往往是解压、找 JSON、套一个双塔模型开跑真正卡住进度的通常不是模型结构而是压缩包里视频编号和标注字段对不上。这个数据集是视频文本检索、视频描述任务里绕不开的硬基线约一万段短视频每段配多条英文描述带常见的 train/val/test 划分v2.0 对标注做过一轮修正。它适合验证跨模态对齐、检索召回和描述生成的数据管道不适合拿来做“跑通就发榜”的幻想。下面按我实际拆包、抽帧、建 Dataset、排查翻车的顺序讲目标是你照着能把 10K 视频变成可训练的 clip-text 对。2. 拆开 rar 先看骨头目录、JSON 字段与 10K 视频的对应关系2.1 解压与完整性校验unrar/7z 的最小命令压缩包到手先别双击解压。rar 包常见的问题是分卷没下全、路径里有空格、解压到当前目录后文件散落最后你看到一堆 mp4却不知道少了哪几个。我一般先建独立目录再用7z l看列表确认条目数和目录层级最后才解压。解压完不要只看“有没有 mp4”要统计数量、总大小并把视频文件名和 JSON 里的video_id做集合比对。# 建独立目录避免解压后文件散落在当前工作区 mkdir -p data/msrvtt10k # 先看压缩包列表确认是否存在分卷、异常路径或多余目录 7z l MSR-VTT 10K v2.0.rar | head -n 40 # unrar 的 x 表示保留目录结构解压-o 表示覆盖已存在文件 unrar x MSR-VTT 10K v2.0.rar data/msrvtt10k/ -o # 没有 unrar 的机器可以用 7z-o 后面直接跟输出目录不要留空格 7z x MSR-VTT 10K v2.0.rar -odata/msrvtt10k -y # 统计 mp4 数量与总大小先确认不是只解出一半 find data/msrvtt10k -type f -iname *.mp4 | wc -l du -sh data/msrvtt10k这段命令的逻辑是先“看”再“解”最后“验”。参数上unrar x会保留压缩包内目录结构适合带子目录的包7z x的-o和输出目录之间不能有空格这是很多人第一次用 7z 会踩的小坑。-y表示自动确认批处理时省交互。find里用-iname而不是-name是因为有些包内扩展名会写成.MP4。如果find统计出来的数量明显不对先别怀疑数据本身回到压缩包列表看是不是分卷缺失或解压中断。注意解压前先看压缩包列表分卷缺失时unrar x可能只解出一部分文件返回码却不一定明显报错。2.2 train_val_videodatainfo.json 与 test_videodatainfo.json 的字段读法10K 包的核心不是 mp4而是两个标注 JSON一个通常叫train_val_videodatainfo.json另一个叫test_videodatainfo.json。不同转存版本可能在压缩包内多套一层目录所以第一步不是硬编码路径而是用rglob把 JSON 找出来。常见结构里顶层会有videos和sentences两个列表videos描述视频本身sentences描述每个视频对应的多条英文句子。test 文件有时不带完整的videos列表只保留句子这时候代码里要用get做容错不能直接下标。文件常见顶层 key关键内容用途train_val_videodatainfo.jsonvideos、sentences视频编号、类别、句子文本、句子编号构建训练与验证样本test_videodatainfo.jsonsentences有时带videos测试视频对应的描述句构建测试集与检索库视频目录无video0.mp4到video9999.mp4这一类文件抽帧、解码、缓存import json from pathlib import Path root Path(data/msrvtt10k) # 压缩包内可能多一层目录先递归找标注文件别硬编码一层路径 json_files sorted(root.rglob(*videodatainfo*.json)) print(找到的标注文件:, json_files) def load_info(path): with open(path, r, encodingutf-8) as f: return json.load(f) # 按文件名区分 train_val 与 test避免路径层级变化导致读错 train_val_path next(p for p in json_files if train_val in p.name) test_path next(p for p in json_files if test in p.name) train_val load_info(train_val_path) test load_info(test_path) print(train_val keys:, train_val.keys()) print(test keys:, test.keys()) print(train_val 视频数:, len(train_val.get(videos, []))) print(train_val 句子数:, len(train_val.get(sentences, []))) print(test 句子数:, len(test.get(sentences, []))) print(第一条句子样本:, train_val[sentences][0])读取代码的关键是容错json_files用rglob找不假设压缩包解压后只有一层train_val_path和test_path用文件名匹配不写死绝对路径get(videos, [])是为了兼容 test 文件没有完整视频列表的情况。参数上encodingutf-8必须显式写Windows 默认编码有时会把标注里的特殊符号读坏。打印第一条句子样本很重要你能直接看到video_id是video0还是0caption字段到底叫caption还是sentence后面写 Dataset 时少返工。2.3 视频编号、句子编号与划分先建一张不建模也能看的表在写任何模型之前我建议先产出一张 CSV每行是一个“视频-句子”对列包括split、video_id、sen_id、caption、caption_len。这张表不建模也能看能帮你发现视频编号补零不一致、句子重复、某个视频句子数异常、train/val 划分是否按视频切分。10K 包常见划分是 6513/497/2990 这一类固定视频级划分如果压缩包里没有单独的 split 字段不要按句子随机切否则同一个视频的 20 条句子会跨 train 和 val验证指标会虚高。import csv import re from collections import Counter def norm_video_id(v): s str(v).strip() # 兼容 video0、0、video_0 这几种写法统一成 video0 m re.search(r(\d)$, s) return fvideo{int(m.group(1))} if m else s def norm_caption(s): return re.sub(r\s, , str(s).strip()) # 先从 train_val 的 sentences 里收集视频编号 train_val_video_ids sorted({ norm_video_id(s[video_id]) for s in train_val[sentences] }) # 常见固定划分前 6513 个视频做 train接着 497 个做 val # 如果包内自带划分清单优先用清单不要用这个排序切法 train_ids set(train_val_video_ids[:6513]) val_ids set(train_val_video_ids[6513:6513 497]) records [] for s in train_val[sentences]: vid norm_video_id(s[video_id]) split train if vid in train_ids else val records.append({ split: split, video_id: vid, sen_id: s.get(sen_id, -1), caption: norm_caption(s[caption]), }) for s in test[sentences]: records.append({ split: test, video_id: norm_video_id(s[video_id]), sen_id: s.get(sen_id, -1), caption: norm_caption(s[caption]), }) print(按 split 统计句子数:, Counter(r[split] for r in records)) print(按 split 统计视频数:, { k: len({r[video_id] for r in records if r[split] k}) for k in (train, val, test) }) with open(msrvtt_records.csv, w, encodingutf-8, newline) as f: writer csv.DictWriter(f, fieldnames[split, video_id, sen_id, caption]) writer.writeheader() writer.writerows(records)这段代码做了三件事编号归一化、按视频划分、落 CSV。norm_video_id用正则提取末尾数字是为了把video0、0、video_0统一成同一种键norm_caption只做空白压缩不在这里做重型清洗避免把标点信息提前丢掉。划分参数 6513/497 是常见固定划分不是每个转存版本都完全一致如果你的目标是跟公开结果对齐优先找包内自带的划分清单或固定视频 ID 列表别用排序切法代替。CSV 落盘后用表格软件按video_id分组看一眼正常情况每个视频在 train 或 val 里只出现一次每个视频的句子数在 20 条上下如果某个视频同时出现在 train 和 val后面指标一定不对劲。3. 把 rar 变成可训练张量抽帧、文本清洗与 Dataset 的最小闭环3.1 视频解码与帧采样8 帧还是 16 帧先定这三个参数视频侧最先定的不是模型而是三个参数抽多少帧、帧分辨率多大、采样策略是均匀取还是首尾取。10K 视频大多在十秒到几十秒量级8 帧适合显存紧、先跑通链路的场景16 帧对动作变化更友好但显存和 IO 都翻倍。我一般先用 8 帧、224×224 跑通确认 loss 能降、检索能出结果再升到 16 帧做对比。采样策略不要只取开头很多视频开头是黑帧或标题卡均匀采样更稳。参数常用起点调整方向影响num_frames8动作细、显存够就 16帧太少漏动作太多重复帧size224320 或 256 视模型而定分辨率高显存和解码成本上升batch_size32 或 64先小后大影响对比学习负样本数量temperature0.070.05 到 0.1 之间试太小训练不稳太大对比不锐利max_text_len48 或 64看句子长度分布截断太多丢主语和动作import cv2 import numpy as np from pathlib import Path def sample_frames(video_path, num_frames8, size224): cap cv2.VideoCapture(str(video_path)) if not cap.isOpened(): raise RuntimeError(f无法打开视频: {video_path}) total int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if total 0: cap.release() raise RuntimeError(f帧数为 0可能编码不支持: {video_path}) # 均匀分桶后取每个桶的中心避免总取到开头黑帧 idxs ((np.arange(num_frames) 0.5) * total / num_frames).astype(int) idxs np.clip(idxs, 0, total - 1) frames [] for idx in idxs: cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx)) ok, frame cap.read() if not ok: continue frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame cv2.resize(frame, (size, size), interpolationcv2.INTER_AREA) frames.append(frame) cap.release() if not frames: raise RuntimeError(f抽帧失败: {video_path}) # 解码少帧时用最后一帧补齐保证张量形状固定 while len(frames) num_frames: frames.append(frames[-1]) return np.stack(frames[:num_frames], axis0)这段抽帧函数的逻辑是“先拿总帧数再均匀分桶取中心最后补齐”。num_frames控制时间分辨率size控制空间分辨率np.clip防止最后一帧索引越界cv2.COLOR_BGR2RGB是因为 OpenCV 默认 BGR而常见视觉编码器按 RGB 训练INTER_AREA在缩小图像时比默认插值更稳。每帧解码失败时continue最后用最后一帧补齐是为了不让一个坏帧把整个 batch 拖垮。但补齐只是保形状不是保语义后面排查章会讲怎么记录失败视频避免静默补零。3.2 文本侧清洗v2.0 里仍要处理的重复句、标点与长度截断v2.0 对标注做过修正但实际使用中仍会遇到同一视频内句子高度重复、大小写混用、标点怪异、句子过长。文本清洗不要做过头小写化、空白压缩、去掉控制字符是安全的把数字全删、把停用词全去掉反而会让“a man is playing guitar”和“a woman is playing guitar”变得难以区分。我一般只做轻清洗然后在同一视频内部去重保留sen_id最小的那条。跨视频不要去重因为不同视频共用“a man is talking”这类句子很正常。import re import unicodedata def clean_caption(text): text unicodedata.normalize(NFKC, str(text)) text text.strip().lower() text re.sub(r\s, , text) # 保留英文、数字、空格和常见标点清掉控制字符 text re.sub(r[^a-z0-9\s\-,.!?], , text) text re.sub(r\s, , text).strip() return text seen {} for r in records: key (r[video_id], clean_caption(r[caption])) if key not in seen: seen[key] r records_dedup list(seen.values()) print(去重前:, len(records), 去重后:, len(records_dedup))清洗函数里NFKC归一化能处理全角字符和兼容字符lower()让小写化一致正则只保留常见英文标点避免标注里混入不可见控制符。去重键用(video_id, caption)不是只用caption这样不会把不同视频的相同句子误删。max_text_len建议先统计句子长度分布再定常见做法是 48 或 64截断时保留前面的词因为视频描述的主语和动作通常在前半句。3.3 写一个可缓存的 PyTorch Dataset避免每个 epoch 重新解码 MP4如果每个 epoch 都重新解码一遍 mp4训练会卡在 IO 上GPU 利用率像心电图。我一般第一次抽帧后把帧数组缓存成.npy缓存键里必须带split、video_id、num_frames、size。缓存键不带split是后面指标异常的常见源头train 和 test 都有video0这类编号时缓存会串。Dataset 本身只负责读缓存或解码、做张量变换、把文本交给 tokenizer不要把模型逻辑塞进去。import numpy as np import torch from torch.utils.data import Dataset from pathlib import Path class MSRVTTDataset(Dataset): def __init__(self, records, video_dir, tokenizer, num_frames8, size224, max_text_len48, cache_dirNone): self.records records self.video_dir Path(video_dir) self.tokenizer tokenizer self.num_frames num_frames self.size size self.max_text_len max_text_len self.cache_dir Path(cache_dir) if cache_dir else None if self.cache_dir: self.cache_dir.mkdir(parentsTrue, exist_okTrue) def _cache_path(self, split, video_id): # 缓存键必须带 split否则 train/test 同名编号会串 return self.cache_dir / f{split}_{video_id}_{self.num_frames}_{self.size}.npy def _load_frames(self, split, video_id): if self.cache_dir: p self._cache_path(split, video_id) if p.exists(): return np.load(p) frames sample_frames( self.video_dir / f{video_id}.mp4, num_framesself.num_frames, sizeself.size, ) if self.cache_dir: np.save(self._cache_path(split, video_id), frames) return frames def __len__(self): return len(self.records) def __getitem__(self, idx): r self.records[idx] frames self._load_frames(r[split], r[video_id]) # T,H,W,C - T,C,H,W并归一化到 0-1 video torch.from_numpy(frames).permute(0, 3, 1, 2).float() / 255.0 encoded self.tokenizer( r[caption], truncationTrue, max_lengthself.max_text_len, paddingmax_length, ) return { video: video, text_ids: torch.tensor(encoded[input_ids], dtypetorch.long), video_id: r[video_id], split: r[split], }这段 Dataset 的关键在_cache_pathsplit、video_id、num_frames、size四个键缺一不可。_load_frames先看缓存没有再解码解码后立刻落盘__getitem__只做张量变换和 tokenizer 调用。参数上max_text_len要和 tokenizer 的截断长度一致num_frames和size改了以后缓存键自动变化不会误读旧缓存。tokenizer从外部传入是为了让 Dataset 不绑定具体文本编码器。缓存目录建议单独放盘不要把几万个.npy和原始 mp4 混在一起后面清理和统计都麻烦。提示缓存键里带 split是后面排查指标异常时最省事的后悔药。4. 血泪踩坑排查MSR-VTT 10K v2.0.rar 从解压到训练最常翻车的 5 个点4.1 现象解压后视频数不是 10000现象find ... -iname *.mp4 | wc -l得到 9998、9999 或更少但压缩包看起来解压完成了。原因rar 分卷没下全解压中途磁盘满压缩包内扩展名大小写不一致视频文件在多层子目录里统计命令路径写错还有一种是解压工具把同名文件覆盖了。解决先用7z l看压缩包条目数再用unrar t做完整性测试最后用find -iname统计。把视频文件名和 JSON 里的video_id做集合差缺失列表打印出来别靠肉眼猜。如果差集集中在某一段编号优先怀疑分卷如果差集随机怀疑覆盖或磁盘错误。unrar t MSR-VTT 10K v2.0.rar find data/msrvtt10k -type f -iname *.mp4 \ | sed s#.*/##; s#\.mp4$## \ | sort /tmp/have_videos.txt python - PY import json from pathlib import Path root Path(data/msrvtt10k) need set() for p in root.rglob(*videodatainfo*.json): with open(p, encodingutf-8) as f: d json.load(f) for s in d.get(sentences, []): need.add(str(s[video_id]).split(.)[0]) have set(Path(/tmp/have_videos.txt).read_text().split()) print(标注视频数:, len(need), 实际视频数:, len(have)) print(缺失示例:, sorted(need - have)[:20]) PY4.2 现象JSON 能读但 key 对不上现象KeyError: caption或者video_id一会儿是video0一会儿是0合并表时对不上。原因不同子集、不同转存版本的字段名不完全一致编号补零规则不同大小写和空格混用。解决写一个兼容读取函数不要在主流程里到处写s[caption]。把caption、sentence、text都试一遍把video_id统一成“末尾数字补成 videoN”的形式。读取时打印第一条样本的 key 和值确认字段再往下走。def get_caption(s): for k in (caption, sentence, text): if k in s: return s[k] raise KeyError(f找不到文本字段: {list(s.keys())})4.3 现象训练 loss 不降现象对比学习跑了上百步loss 一直在ln(batch_size)附近晃Recall1 跟随机差不多。原因同一视频的 20 条描述被当成 20 个独立样本batch 内来自同一视频的句子被互相当成负例形成大量假负例学习率过大把投影层打飞视频帧和文本特征没有做归一化文本 tokenizer 和模型词表不匹配。解决按video_id分组采样让同一个 batch 里尽量一个视频只出现一条句子如果做不到就在相似度矩阵里 mask 掉同video_id的位置。先拿 100 条样本过拟合确认 loss 能接近 0再上全量。学习率从 1e-4 到 1e-3 之间小范围试投影层可以先大一点编码器解冻后要降。4.4 现象随机抽样报解码错误现象DataLoader 随机报无法打开视频或帧数为 0但换一个视频又正常。原因OpenCV 后端对某些编码不支持视频文件本身损坏CAP_PROP_POS_FRAMES对部分容器 seek 不准缓存文件写了一半被中断。解决先用ffprobe看流信息确认视频有几路流、编码是什么、时长是否正常抽帧失败时记录视频名和异常不要静默补零缓存写入用临时文件再重命名避免半截.npy。如果某个视频反复失败单独用 ffmpeg 转一次码再抽帧不要在全量脚本里硬扛。ffprobe -v error -show_streams -show_format data/msrvtt10k/video123.mp44.5 现象验证集指标异常高现象第一次跑验证Recall1 高得不真实换随机种子又掉下来。原因缓存键没带splittrain 和 test 同名编号串了按句子随机切分同一视频的句子跨 train/val测试集句子重复检索时重复命中验证时误用了训练集视频特征。解决缓存键带split划分按video_id切不按句子切测试集去重后保留video_id - 句子列表的映射验证脚本里打印当前 split 的视频 ID 集合确认 train/val/test 没有交集。指标异常高时先查数据泄漏再查模型。5. 用 100 条子集做过拟合验证判断数据管道是否真的通了5.1 小样本过拟合的判据loss、Recall1 与帧覆盖全量训练之前我习惯先拿 100 条样本做一次“能不能记住”的验证。判据不复杂固定随机种子冻结视觉和文本编码器只训投影层200 步内 loss 应该明显下降同一个 batch 内的视频到文本 Recall1 应该接近 1抽出来的 8 帧里首、中、尾都应该有非重复画面不能全是黑帧或标题卡。这个验证不过别怀疑模型结构先回查 JSON 对齐、帧采样和缓存键。检查项通过标准不通过先查100 条 loss200 步内降到 0.05 附近标签对齐、温度系数、学习率batch 内 Recall1接近 1.0帧采样、特征归一化帧覆盖首中尾都有有效画面解码 seek、黑帧过滤缓存命中第二次 epoch 明显变快缓存键、磁盘权限5.2 一个 20 行验证脚本固定种子、冻结文本、只训投影层下面这段是最小验证循环省略了编码器定义重点看数据流和 loss 形状。labels是对角线标签temperature先设 0.07只训投影层时学习率可以到 1e-3解冻编码器后要降一个量级。import torch import torch.nn.functional as F torch.manual_seed(42) model.train() optimizer torch.optim.AdamW(proj.parameters(), lr1e-3) for step, batch in enumerate(loader): video batch[video].to(device) # B,T,C,H,W text batch[text_ids].to(device) # B,L labels torch.arange(video.size(0), devicedevice) with torch.no_grad(): v_feat vision_encoder(video.mean(dim1)) t_feat text_encoder(text) v F.normalize(proj(v_feat), dim-1) t F.normalize(proj(t_feat), dim-1) logits v t.T / 0.07 loss (F.cross_entropy(logits, labels) F.cross_entropy(logits.T, labels)).mean() / 2 optimizer.zero_grad() loss.backward() optimizer.step() if step % 20 0: acc (logits.argmax(dim1) labels).float().mean().item() print(step, round(loss.item(), 4), round(acc, 3))这段循环只验证三件事视频张量能不能进编码器、文本 token 能不能对齐、对比损失能不能把对角线拉起来。video.mean(dim1)是时间维平均的简化写法真实模型可以换成时序池化F.normalize不能省否则温度系数会变成玄学labels用arange是因为 batch 内第 i 个视频配第 i 条文本。跑完 100 条如果 loss 不降我一般不再调模型先把records打印出来看视频文件名、句子、split 是否一一对应。这个习惯帮我省过很多次通宵排查也让我明白数据管道比模型结构更值得先花时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?