各位做自动驾驶数据挖掘、视频理解或者多模态检索的同学,应该都遇到过这样的场景:想从一个大规模驾驶视频库里快速找出“在雨天路口左转”或者“夜间被卡车加塞”的片段,单靠文件名和人工标签几乎不现实,而直接用纯视觉特征做检索,又容易忽略道路结构、车道关系和运动趋势这些驾驶场景里最关键的信息。
最近读到一篇很有意思的工作,标题是TraVEL: Trajectory-Guided Video Embedding Learning for Driving-Video Retrieval,核心思路是用“轨迹”来引导视频嵌入表示的学习,从而提升驾驶视频检索效果。这篇文章我会结合标题语义、技术方案通用逻辑和工程落地经验做一个较系统的拆解,并给出可运行的代码思路与踩坑排查方案,适合正在做自动驾驶数据集检索、多模态视频理解或轨迹感知特征学习的读者。
1. 背景:驾驶视频检索为什么需要轨迹引导
1.1 驾驶视频检索到底难在哪
常规的视频检索任务,通常是从一个视频库里找到与查询条件最匹配的视频片段。查询条件可以是文字,也可以是一段参考视频。自动驾驶领域的视频检索有其特殊性,单纯用图像分类或动作识别的思路往往效果不佳。
自动驾驶视频里真正有价值的信息往往不是“画面里有什么物体”,而是:
- 自车的行驶意图,比如左转、掉头、靠边停车;
- 周围交通参与者的运动状态,比如前车急刹、行人横穿;
- 道路拓扑结构的变化,比如即将进入环岛、车道线合并;
- 当时的环境条件,比如雨天积水反光、夜间对向远光。
这些信息是动态的、时序的、强相关的。用普通视频分类网络提取的全局特征,容易损失空间细节和运动趋势,导致检索出来的结果在视觉上相似,但在驾驶语义上并不匹配。
1.2 轨迹数据为什么能帮上忙
如果你看过自动驾驶实车数据或者开源数据集(比如 nuScenes、Argoverse、BDD-X 等)的标注格式,一定会注意到:除了图像和视频,通常还包含高精地图、自车轨迹和障碍物轨迹。这些轨迹数据看似是为规划控制准备的,但在检索任务里其实可以发挥非常关键的作用。
一条车辆的行驶轨迹,本质上就是自车与周围环境交互结果的高度浓缩。它反映出这辆车遇到了怎样的路况、驾驶员做出了怎样的决策、场景在时间轴上是如何演变的。如果直接用轨迹标签做监督信号,去引导视频编码器学习嵌入表示,模型更容易理解“视频里正在发生什么驾驶事件”,而不是仅仅停留在物体检测和场景分类的表层。
1.3 TraVEL 想解决什么问题
从论文标题可以直观看出,TraVEL 的学习范式大概是这样的:
- 视觉输入是驾驶视频,即一段连续的车辆行驶画面;
- 辅助监督是轨迹,即车辆在时间轴上的位置、速度、转向等运动信息;
- 学习目标是视频嵌入表示,让相似驾驶场景和行为在嵌入空间里彼此靠近;
- 应用任务是驾驶视频检索,包括视频到视频检索,以及可能扩展到文字到视频检索。
这种设计的出发点用一个通俗说法来概括就是:让模型看视频的同时,还知道车辆实际是“怎么开”的,把“怎么开”的信息编码进视频特征里。轨迹在多模态对比学习中承担了类似“桥接信号”的角色,能把视觉内容和驾驶语义连接起来。
2. 问题定义与总体技术思路拆解
2.1 用数学语言描述视频检索任务
给定一个驾驶视频片段 (V_i),我们需要学习一个编码函数 (f),把视频映射成一个固定维度的嵌入向量 (e_i = f(V_i))。在检索时,对于查询视频 (V_q),模型计算它和库存视频 (V_j) 的嵌入相似度,比如余弦相似度:
[ score(q, j) = \frac{e_q \cdot e_j}{|e_q| |e_j|} ]
分数越高,说明两个视频在语义上越接近。这条链路里面,最关键、最影响上限的环节就是嵌入函数 (f) 的质量。如果单纯用视频动作识别的预训练模型做编码器,得到的嵌入表示可能偏向粗粒度行为分类,而不是驾驶场景特有的细粒度语义。
2.2 轨迹如何进入学习流程
轨迹信息在训练阶段进入学习流程,是为了让模型知道视觉特征和驾驶行为之间的对应关系。常见做法有这几种:
- 轨迹作为额外模态,和视频特征做跨模态对比学习;
- 轨迹作为监督标签,预测未来的自车横向/纵向行为;
- 轨迹文本化之后,用语言模型嵌入参与多模态对齐。
从 TraVEL 这个名字推测,它的特色是把轨迹作为“引导信号”而不是简单的分类标签:模型不再只是“记住这一段是左转”,而是学习“哪些视觉特征会导致左转这条轨迹”,从而把轨迹预测和表征学习联合起来。
2.3 训练与推理阶段的区别
既然标题强调“Embedding Learning”,那轨迹通常是训练阶段才使用的强监督信息。部署阶段的推理流程,理论上只需要输入视频片段,得到嵌入向量后做最近邻检索。对检索系统来说,这是很大的工程红利,因为车辆不一定实时提供干净轨迹,也可以完成语义检索。
当然,如果系统在推理时也能拿到轨迹,比如来自自车传感器,那么轨迹和视觉可以一起编码,检索效果会更好。这种“训练时用轨迹,推理时可视情况融合”的设计,给实际接入留了很大的灵活度。
3. 方法框架与核心模块设计
注意:截止文章撰写时,该方向的论文公开代码与超参细节需要以原作者发布版本为准。下面是根据标题语义和常规多模态视频表征范式整理的设计框架,可以作为理解论文和独立复现的工程参考,不保证逐字等效于原论文。
3.1 整体结构
通常一个完整的方法会包括:
- 视频编码模块:把一段驾驶视频编码成时序特征;
- 轨迹编码模块:把车辆运动轨迹编码成轨迹特征;
- 投影对齐模块:把视频特征和轨迹特征映射到同一语义空间;
- 对比学习模块:在视频-轨迹配对数据上做自监督/弱监督对齐;
- 检索应用模块:使用视频嵌入做召回和排序。
一个大致的处理流程如下:
输入驾驶视频片段 ↓ 抽帧 -> 视频编码器(3D Conv / Video Transformer) ↓ 得到视频令牌序列 ↓ 时序聚合 -> 全局视频嵌入 ↓ 训练时接入轨迹对比学习,推理时直接用视频嵌入做检索3.2 视频编码器选择
视频编码器可以直接使用现成的动作识别模型,比如 SlowFast、Video Swin Transformer、TimeSformer 等。关键点在特征提取后的处理:
- 不能只用最后一帧的特征,那会丢掉时间上下文;
- 不能简单对所有帧平均池化,重要事件可能只持续半秒;
- 建议用 Temporal Attention 或可学习的 Query 聚合方式,让模型自己关注最关键的帧区域。
如果算力有限,也可以退而求其次用 2D CNN 逐帧提特征,再用 LSTM、GRU 或 Transformer 做时序建模。考虑到驾驶视频的动态性,2D CNN + 时序模块的下限不低,且更容易训练。
从工程上看,输入视频的处理一般要先抽帧,帧率建议统一。驾驶场景变化快,如果原始视频是 30fps,而抽帧间隔过大,很多短暂交互会丢。这里需要结合存储和算力做权衡。
3.3 轨迹编码器设计
轨迹数据一般是一个时间序列,里面包含若干项,常见的字段有:
- timestamp,时间戳;
- 自车的 x、y 坐标;
- 自车航向角;
- 自车速度、加速度;
- 转向角、方向盘转角。
轨迹编码器要先做坐标归一化,尤其要注意不同路口的绝对坐标差异很大,如果直接把经纬度或全局坐标输入模型,模型很难泛化。业界常见的做法是把轨迹转换到以自车起始位置为原点的局部坐标系:
x_local_t = x_t - x_0 y_local_t = y_t - y_0 theta_local_t = theta_t - theta_0然后再拼上速度和加速度等运动学量,形成多通道输入。
轨迹序列可以使用带掩码的 Transformer 编码器,也可以使用带 Mask 的一维卷积或 GRU。由于驾驶轨迹长度通常不超过几十秒,模型规模不需要很大,一个 4 到 6 层的 Transformer 编码器效果基本足够。额外建议是在特征向量中保留时间位置编码,不然轨迹后期可能发生过拟合。
3.4 跨模态对齐模块
轨迹特征和视频特征在维度、语义空间上差异很大。跨模态对齐模块通常分为两层:
第一层是投影头,比如视频侧是一个 MLP,轨迹侧是另一个 MLP,把特征投影到相同的维度 (d)。第二层是对齐损失函数,常用的有 InfoNCE 对比损失、Triplet 损失或 KL 散度对齐。
InfoNCE 的直觉是:给定一个视频,应该能从库里众多轨迹样本中找到与它真正匹配的那条轨迹。对比学习通过拉近正样本对,推远负样本对,在嵌入空间里形成语义聚类。
假设 batch size 为 N,每个 batch 里有 N 个视频和对应的 N 条轨迹,InfoNCE 损失可以写成:
[ L = -\frac{1}{N} \sum_{i=1}^{N} \log \frac{\exp(sim(v_i, t_i) / \tau)}{\sum_{j=1}^{N} \exp(sim(v_i, t_j) / \tau)} ]
其中 (\tau) 是温度系数,控制分布的平滑程度。温度过小,模型会过度关注难负样本,训练不稳定;温度过大,正负样本差异被平滑掉,学不到区分性特征。实际使用中 (\tau) 通常在 0.05 到 0.2 之间调整。训练中最容易踩的坑是,“视频来自同一场景的不同片段”也会被当成负样本,导致模型学到错误的排斥关系。
3.5 轨迹与视频的粒度匹配问题
这是一个非常容易被忽略但特别影响效果的细节。
视频片段可能是 8 秒、16 秒甚至更长,轨迹可能是全程连续的。如果整段视频只有一条完整轨迹,那么模型只能在很粗的粒度上对齐。但驾驶行为往往是分段式的:先直行,再在路口左转,然后加速驶离。
如果能把轨迹裁切到与视频片段相同的时间窗口,对齐信号会干净得多。实现上,不是简单按时间均匀切,而是保留一个重叠窗口。比如视频片段是从第 3 秒到第 11 秒,那轨迹也截取第 3 秒到第 11 秒的数据,并且最好在前后各保留 1 秒作为缓冲。这样模型能感知到视频片段发生的完整行车事件边界。
时间窗口对齐后,还可以在对比损失里加上“时间近邻即软正样本”的容错机制。因为轨迹和视频的时间戳来自不同传感器,可能存在几百毫秒的偏移,严格对齐很容易引入噪声样本。
4. 环境准备与训练数据组织
4.1 基础环境版本建议
下面环境基于常见 PyTorch 生态验证,不同版本之间差异较大,使用时请以本机实际环境为准。本文写作时推荐的组合是:
| 组件 | 建议版本/方案 |
|---|---|
| 操作系统 | Ubuntu 20.04 / 22.04 |
| Python | 3.8 或 3.10 |
| PyTorch | 1.13+ 或 2.0+ |
| CUDA | 11.7 / 11.8 或更高 |
| 深度学习框架 | PyTorch / PyTorch Lightning 皆可 |
| 视频解码 | OpenCV + PyAV |
| 向量检索库 | Faiss(CPU 版即可起步) |
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。如果使用更新的 PyTorch 版本,个别 API 可能有变动,需要自行阅读 Release Note 调整。
4.2 驾驶视频数据集里的轨迹标注
公开的驾驶数据集里面,轨迹和视频同时存在的不算特别多,比较需要关注协议和许可。常见方式有三种:
- nuScenes 提供 360 度环视视频和自车/物体轨迹;
- Argoverse 提供传感器数据和地图轨迹,但视频使用方式需要看版本;
- BDD-X 提供前置摄像头视频以及驾驶行为文本描述,更适合做多模态对齐但轨迹字段需要换算。
即使只有一个简单的前视视频和一份 GPS/IMU 轨迹文件,也可以用本文章的方法思路来学习嵌入,不一定要依赖完整的高精地图标注。你只需要“视频片段”和“自车运动轨迹”两个信号。
一个最朴素的训练数据 CSV 或 JSON 示例大概长这样:
[ { "video_path": "/data/videos/segment_0001.mp4", "start_frame": 120, "end_frame": 360, "trajectory_path": "/data/trajectories/segment_0001.csv" }, { "video_path": "/data/videos/segment_0002.mp4", "start_frame": 90, "end_frame": 400, "trajectory_path": "/data/trajectories/segment_0002.csv" } ]轨迹 CSV 表头建议如下:
timestamp,x,y,speed,acceleration,heading,steering 0.0,0.0,0.0,8.5,0.2,1.52,0.03 0.1,0.85,-0.1,8.6,0.15,1.53,0.02 ...其中误差项在数据切分时要格外注意,如果程序对时间戳做了取整,不同轨迹的采样率不一致,很容易让对齐出现偏差。建议在预处理阶段统一插值到固定频率(比如 10Hz)。
4.3 视频抽帧与保存策略
训练时频繁从 mp4 里随机读帧会很慢。推荐的做法是首次预处理时把视频抽成均匀帧序列,以 JPEG 或 PNG 保存,并在 manifest 里记录帧索引。如果你的磁盘空间有限,可以在线读取 mp4 并用 PyAV 做关键帧定位,但速度会慢一些。
一个提取视频帧并保存的脚本示例如下:
# 文件路径:tools/extract_frames.py import cv2 import os import argparse def extract_frames(video_path: str, output_dir: str, fps: int = 5): os.makedirs(output_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) video_fps = cap.get(cv2.CAP_PROP_FPS) frame_interval = max(1, int(round(video_fps / fps))) frame_idx = 0 saved_idx = 0 while True: ret, frame = cap.read() if not ret: break if frame_idx % frame_interval == 0: out_path = os.path.join(output_dir, f"frame_{saved_idx:06d}.jpg") cv2.imwrite(out_path, frame) saved_idx += 1 frame_idx += 1 cap.release() print(f"Saved {saved_idx} frames to {output_dir}, source_fps={video_fps}") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Extract frames from driving video.") parser.add_argument("--video_path", type=str, required=True) parser.add_argument("--output_dir", type=str, required=True) parser.add_argument("--fps", type=int, default=5) args = parser.parse_args() extract_frames(args.video_path, args.output_dir, args.fps)抽帧频率建议先不要设太高。按 5fps 抽帧,一段 16 秒的视频也就 80 帧。不要一开始就按 30fps 全量抽帧,那样训练显存占用会急剧上升,而带来的收益不一定很大。如果后面需要做高精度检索,可以再考虑关键帧插值。
对于已有的剪辑片段,时间长度也不宜过短。太短的视频缺乏足够运动上下文,轨迹信号很难发挥作用;太长则显存压力大,也不利于构建大批量训练。8 到 16 秒是相对平衡的选择。
5. 训练流程和核心代码实现
5.1 数据加载器设计
数据加载器的目标是把视频片段和对应的轨迹片段组织成训练对。为了让模型更好地学习和理解,我们可以在 Dataloader 里加入时序对齐和简单数据增强。
在这个片段里,增强方式包括对视频随机裁剪、颜色扰动、水平翻转。需要注意的是,如果对视频做了水平翻转,轨迹数据的横向坐标、航向角、方向盘转角都要做符号取反,否则视频画面和轨迹的语义会不一致。
# 文件路径:dataset/driving_dataset.py import os import random import csv import cv2 import numpy as np import torch from torch.utils.data import Dataset def read_trajectory(csv_path: str): rows = [] with open(csv_path, "r", encoding="utf-8") as f: reader = csv.DictReader(f) for line in reader: rows.append( { "timestamp": float(line["timestamp"]), "x": float(line["x"]), "y": float(line["y"]), "speed": float(line["speed"]), "acceleration": float(line["acceleration"]), "heading": float(line["heading"]), "steering": float(line["steering"]), } ) return rows class DrivingVideoDataset(Dataset): def __init__(self, manifest_path, frame_dir, traj_dir, num_frames=16, traj_len=20): super().__init__() self.frame_dir = frame_dir self.traj_dir = traj_dir self.num_frames = num_frames self.traj_len = traj_len with open(manifest_path, "r", encoding="utf-8") as f: self.samples = [json.loads(line) for line in f if line.strip()] def __len__(self): return len(self.samples) def _load_video_frames(self, sample): # 这里简化成从已抽帧目录按索引读取图片 # 实际可自行根据 sample["start_frame"] 和 sample["end_frame"] 定位 frame_files = sorted( [ f for f in os.listdir(self.frame_dir) if f.endswith(".jpg") ] ) selected = [] step = len(frame_files) / self.num_frames indices = [min(int(i * step), len(frame_files) - 1) for i in range(self.num_frames)] for idx in indices: img = cv2.imread(os.path.join(self.frame_dir, frame_files[idx])) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (224, 224)) selected.append(img) return np.stack(selected, axis=0) def _load_trajectory_feature(self, sample): traj = read_trajectory(os.path.join(self.traj_dir, sample["trajectory_path"])) # 统一插值/采样到 traj_len step = len(traj) / self.traj_len sampled = [] for i in range(self.traj_len): idx = min(int(i * step), len(traj) - 1) p = traj[idx] sampled.append([p["x"], p["y"], p["speed"], p["acceleration"], p["heading"], p["steering"]]) return np.array(sampled, dtype=np.float32) def __getitem__(self, idx): sample = self.samples[idx] video = self._load_video_frames(sample) traj = self._load_trajectory_feature(sample) video = torch.as_tensor(video, dtype=torch.float32).permute(3, 0, 1, 2).div_(255.0) traj = torch.as_tensor(traj, dtype=torch.float32) return {"video": video, "trajectory": traj}注意上面代码只是一个数据流骨架,实操时需要根据实际目录结构修改路径逻辑。轨迹采样中的插值是一个很值得优化的点,这里先用均匀抽样,实际中使用线性插值会更稳。
5.2 视频编码器与轨迹编码器
视频编码器可以基于 Video Swin Transformer 或者 3D ResNet。如果资源有限,先用一个简单的 3D CNN 作为基线模型跑通流程,之后再替换成更强的视频主干网络。
下面的示例用一个基于视频 Swin 的流程做前向演示,具体实现细节需要看所使用的开源库。为了说明整体结构,这里使用一个简化版的单流视频编码器加轨迹 Transformer 的伪代码:
# 文件路径:models/embedding_models.py import torch import torch.nn as nn import torch.nn.functional as F class VideoEncoder(nn.Module): def __init__(self, embed_dim=256): super().__init__() # 注意:这里用 2D 卷积 + 时序平均池化作简化示例 # 实际工程可以替换成 Video Swin Transformer 等 3D 模型 self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3) self.conv2 = nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1) self.conv3 = nn.Conv2d(128, 256, kernel_size=3, stride=2, padding=1) self.pool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(256, embed_dim) def forward(self, video): # video: [B, C, T, H, W] B, C, T, H, W = video.shape x = video.permute(0, 2, 1, 3, 4).reshape(B * T, C, H, W) x = F.relu(self.conv1(x)) x = F.relu(self.conv2(x)) x = F.relu(self.conv3(x)) x = self.pool(x).flatten(1) # [B*T, 256] x = x.view(B, T, -1) x = x.mean(dim=1) # 简单平均池化,生产级建议用 attention 聚合 return self.fc(x) class TrajectoryEncoder(nn.Module): def __init__(self, input_dim=6, embed_dim=256, num_heads=4, num_layers=2): super().__init__() self.input_proj = nn.Linear(input_dim, embed_dim) encoder_layer = nn.TransformerEncoderLayer( d_model=embed_dim, nhead=num_heads, batch_first=True, ) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.fc = nn.Linear(embed_dim, embed_dim) def forward(self, traj): # traj: [B, T, input_dim] x = self.input_proj(traj) x = self.transformer(x) x = x.mean(dim=1) return self.fc(x) class ProjectionHead(nn.Module): def __init__(self, in_dim=256, hidden_dim=128, out_dim=256): super().__init__() self.fc1 = nn.Linear(in_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, out_dim) def forward(self, x): x = F.relu(self.fc1(x)) return F.normalize(self.fc2(x), p=2, dim=-1)从代码可以看出,视频侧最终输出和轨迹侧最终输出都会被 L2 Normalize 到单位球面上。这样做的原因很简单:训练时计算余弦相似度更方便,也能防止向量模长差异造成优化不稳定。真正做检索时,所有视频向量入库前也建议做一样的 Normalize。
5.3 对比损失与训练循环
InfoNCE 损失函数虽然有很多封装好的 API,但为了确保可控性,我建议自己实现一个版本。下面是简洁的 PyTorch 实现:
# 文件路径:losses/contrastive_loss.py import torch import torch.nn as nn class InfoNCELoss(nn.Module): def __init__(self, temperature=0.1): super().__init__() self.temperature = temperature def forward(self, video_emb, traj_emb): # video_emb: [B, D], traj_emb: [B, D] # 归一化后的特征,直接用余弦相似度矩阵 logits = video_emb @ traj_emb.t() / self.temperature labels = torch.arange(logits.size(0), device=logits.device) loss_v2t = nn.CrossEntropyLoss()(logits, labels) loss_t2v = nn.CrossEntropyLoss()(logits.t(), labels) return (loss_v2t + loss_t2v) / 2常规训练循环方式如下:
# 文件路径:train_contrastive.py import torch import torch.optim as optim from torch.utils.data import DataLoader from dataset.driving_dataset import DrivingVideoDataset from models.embedding_models import VideoEncoder, TrajectoryEncoder, ProjectionHead from losses.contrastive_loss import InfoNCELoss def train_one_epoch(model_dict, dataloader, optimizer, criterion, device): video_encoder, traj_encoder, video_head, traj_head = model_dict video_encoder.train() traj_encoder.train() video_head.train() traj_head.train() total_loss = 0.0 for batch in dataloader: video = batch["video"].to(device) # [B, 3, T, H, W] traj = batch["trajectory"].to(device) # [B, T_traj, 6] optimizer.zero_grad() video_raw = video_encoder(video) traj_raw = traj_encoder(traj) video_emb = video_head(video_raw) traj_emb = traj_head(traj_raw) loss = criterion(video_emb, traj_emb) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)训练过程中不需要每次都保存完整 checkpoint,建议在每个 epoch 结束后做一次验证集检索,然后保存与验证集最优 R@1 对应的权重。R@1 的估算可以放在验证阶段完成。
5.4 验证检索指标
检索任务中常用的指标是 Recall@K(R@K),含义是“在检索结果的前 K 个结果中命中正确样本的比例”。驾驶视频检索里最常用的有 R@1、R@5、R@10,以及对应的中位数排名(MedR)。
在一个批次里计算 R@1 的简化版本:
# 文件路径:evaluation/metrics.py def recall_at_k(sim_matrix, k=1): # sim_matrix: [query_num, gallery_num] # 每一行代表一个 query 与所有 gallery 的相似度 # 假设对角线上是正样本 n = sim_matrix.size(0) topk_indices = sim_matrix.topk(k=k, dim=1).indices # [n, k] hits = 0 for i in range(n): if i in topk_indices[i]: hits += 1 return hits / n离线验证一般是从测试集拿出多个视频做互相检索,需要注意不要拿同一段视频的相邻裁剪片段同时出现在 query 和 gallery 里,否则指标会虚高。这种情况很容易出现在视频拆条阶段,如果没有设置最少间隔帧,模型在检索时会匹配到几乎一样的帧,导致 R@1 看起来很高但实际没有语义检索能力。
6. 从对比学习到强语义检索的进阶设计
6.1 视频片段之间的相似事件难区分
用“自车轨迹”做对齐,模型虽然能捕捉换道、转弯、停车等明确行为,但面对两段同样是在十字路口直行的不同视频,视觉内容变化很大、轨迹却高度相似。这会迫使模型在轨迹判别力不足时,退化成只关注行驶指令分类。
这种情况下应引入周围障碍物相对自车的轨迹,比如前车距离变化曲线、侧后方来车的相对速度,这能让模型区分“畅通直行”和“旁边有大车并行”等多种复杂场景。如果手边只有自车轨迹,也可以用光流或车辆检测的跟踪结果来近似周围目标的相对运动。
补充轨迹来源后,最好按语义角色把轨迹拆成一组集合,例如自车轨迹一条、左侧目标轨迹、右侧目标轨迹、前车轨迹。不要简单拼接成一个长向量,那样会让模型忽略不同目标的独立运动趋势。
6.2 引入文本描述做三模态对齐
如果数据集包含文本描述(比如 BDD-X 里有“因为前车减速,自车开始变道”这种解释),可以在视频-轨迹对比学习基础上加入文本模态。这样做的收益是:文本可以为嵌入空间提供更自由的语义监督,轨迹更适合描述精确数值,甚至有助于缓解个别模态缺失导致的检索失效。
损失函数可以从简单的 InfoNCE 扩展成多模态 NCE:
[ L = L_{v \leftrightarrow t} + L_{v \leftrightarrow s} + L_{t \leftrightarrow s} ]
其中 (v) 代表视频,(t) 代表轨迹,(s) 代表文本。每个对比项可以采用双向损失。需要警惕的是,文本描述通常是整段视频级标注,轨迹如果切分得太碎,文本语义可能和轨迹片段对不上,影响对齐效果。
6.3 检索库和特征存储
训练好模型后,在真正落地“驾驶视频检索”时,需要将全部视频片段离线编码成嵌入向量。将这些向量结构化存储是关键工程环节。
可以使用 Faiss 建立索引,下面给出一个滑动窗口视频特征入库的示例,重点是让后续检索支持“哪一段、哪个时间区间”的回溯:
# 文件路径:retrieval/build_index.py import numpy as np import faiss # 假设 embeddings 是已经归一化后的 [N, D] 数组 # metadata_list 是与每行对应的字典,记录视频路径和时间区间 embeddings = np.random.random((1000, 256)).astype("float32") faiss.normalize_L2(embeddings) dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) index.add(embeddings) # 保存索引和元数据 faiss.write_index(index, "driving_video.index") # 检索时: query_embedding = np.random.random((1, 256)).astype("float32") faiss.normalize_L2(query_embedding) scores, indices = index.search(query_embedding, k=10) print(indices)生产和检索时有一个很重要的细节:不是每段视频只切一次窗,窗口太长会让召回精度变差。实际可以按“多尺度滑窗”入库,比如 8 秒、12 秒、16 秒窗口分别生成嵌入向量,检索时综合多个尺度的排名。这样可以兼顾驾驶事件长短不一的问题。窗口的步长建议设为 2 秒,这样既避免冗余存储,也能尽量捕捉事件边界。
7. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 训练早期 loss 下降很快,验证 R@1 却很低 | 模型只学到轨迹中的简单特征,比如车速,没有真正关联视频 | 增加视频-轨迹时间对齐校验,加强负样本难度 |
| 检索结果中同一段视频相邻片段扎堆,无法定位真正事件 | 滑窗重叠太多,训练集和测试集泄漏 | 评估时把同一源视频的邻近窗口排除出候选集 |
| 轨迹坐标使用全局经纬度,模型很难泛化到新路段 | 坐标没有归一化到自车局部坐标系 | 转换为相对起始点的局部坐标,再对横向纵向速度归一化 |
| 视频帧率不一致导致轨迹时序偏移 | 不同数据源采集频率不同 | 统一插值到固定频率,保证轨迹和视频的时间轴一致 |
| 对比学习 batch size 小,效果明显变差 | InfoNCE 依赖大量负样本 | 增大 batch 至 128 以上,或使用 memory bank / MoCo 方式 |
| 推理时拿不到轨迹,检索效果大幅下降 | 训练和推理不一致 | 设计随机丢弃轨迹分支的“双路径”训练策略,让网络不只在有轨迹时工作 |
| 显存不足无法做视频 3D 建模 | 视频 Transformer 计算量太大 | 先降分辨率,或采样更少关键帧,再用大模型蒸馏小模型 |
| 轨迹数据有噪声,部分点跳变 | 传感器丢帧或数据处理出错 | 速度/加速度滤波,超出合理范围的轨迹点直接删除或插值 |
排查这些问题的建议顺序是:先确定数据预处理有没有错,再看训练对齐标签有没有泄漏,最后调模型结构和超参数。很多检索效果不佳的根源,都是数据切分时没有做好“视频片段与轨迹时间窗口一一对应”这件事。
7.1 典型问题一:损失已经收敛但检索结果没有语义关联
这种情况需要直接可视化嵌入空间。一般做法是选出几个驾驶场景,比如左转、直行、靠边停车,看它们的视频嵌入是否形成聚类。如果视频嵌入和轨迹嵌入的分布没有很好对齐,通常说明投影头表达能力不够,或者视频编码器主干特征不够鲁棒。
解决办法:先在相同数据集上微调视频预训练模型,不要从随机初始化开始训练。然后使用更强的轨迹特征,例如加入曲率变化、横向偏移量、相对前车距离等。这些手工特征看似传统,却能让对比学习更稳定。
7.2 典型问题二:同一个视频的相邻帧全部排在前面
驾驶视频里相邻几秒的画面变化不明显,所以模型很容易把同一视觉来源的片段聚在一起。如果做的是“事件级检索”,这不是大问题;如果做“片段检索”,就需要在评估阶段设置最小时间间隔,比如同一个原始视频片段中,两个窗口之间至少间隔 10 秒,才允许互相成为候选。
检索服务在应用层也应该过滤掉来自同一源视频、时间戳高度重叠的结果。否则用户输入一段查询视频后,返回列表可能被同一个长视频切出来的多个窗口占满,无法给出多样化结果。
8. 工程部署与生产建议
8.1 特征缓存与增量更新
驾驶视频的采集是一个持续过程,新的数据会不断产生。入库模块应该支持增量更新:
- 新视频进入后,先检测视频质量、时间戳完整性;
- 只有包含有效轨迹或者有效自车运动状态的片段才进入切窗流程;
- 切窗完成视频,经过模型编码后写入 Faiss 索引;
- 索引定期合并,而不是每条数据都重建全量索引。
Faiss 的 IndexFlatIP 是暴力精确检索,数据量大之后内存占用高。如果视频库超过百万级片段,推荐先使用 IVF 或 HNSW 索引做召回,再用精确距离做重排。这里涉及一个权衡:底层索引的召回速度与精度。你需要结合自己的数据库规模和硬件配置选择。
8.2 如何应对不同传感器数据异源
很多自动驾驶数据并不是统一标准。有的轨迹是 CAN 总线记录的速度和方向盘转角,有的轨迹是高精定位系统输出的经纬度和航向。转换成统一格式时,最好在中间层把它们映射到一个标准结构,字段包括:
- timestamp:统一使用 Unix 时间戳或相对视频起始时间;
- x / y:局部平面坐标,单位统一为米;
- velocity:纵向速度,单位统一为 m/s;
- yaw_rate:偏航角速度;
- steering:如果可用,方向盘转角,注意不同车型方向和符号。
这里最容易出错的是横摆角速度的正负方向和方向盘转角的正负方向,不同车型定义并不一致。建议预处理时先用一小段有标签的数据做可视化对比。拿一段直路加上一个左转弯的视频,画出轨迹和方向盘转角曲线,能直接看出符号是否反了。
8.3 Prompt 式的检索扩展:按驾驶行为描述找视频
等视频和轨迹对齐的嵌入表征训练好之后,可以通过一个轻量文本编码器把描述文本投影到同一空间。比如:
- “前方车辆急刹车,自车也减速”
- “雨天在十字路口左转”
- “夜间高速直行,左侧有车超车”
如果文本与轨迹的嵌入空间已经做过对齐,理论上文本查询可以直接和视频向量算相似度。跨模态检索简化了产品交互形态,但需要保证文本标注的一致性和分布覆盖。实际使用中,可以先用一个驾驶场景分类器限制候选集,再走跨模态检索,降低误召回。
9. 总结与后续学习建议
围绕 TraVEL 这个主题,我们拆解了一个很关键的点:驾驶视频检索的表征学习,不能只依赖画面内容,还要把自车和周围交通参与者的轨迹信息作为引导信号。用轨迹做监督或跨模态对齐,能让嵌入空间更像“驾驶语义空间”,而不只是“像素外观空间”。
如果之后自己动手实现,建议按这个顺序来:
- 先找一个公开或自采的小规模驾驶视频数据集,只做两模态对比学习;
- 用较简单的视频编码器跑通训练和检索验证,理解时间对齐的重要性;
- 再把轨迹切窗和困难负样本挖掘加进去,观察 R@1 的变化;
- 最后替换更强的视频主干和高维索引,面向生产环境优化。
对一个检索系统而言,模型只是其中一环。数据切分、轨迹清洗、时间对齐、索引构建、去重策略和评估指标,每一个环节都可能让最终效果产生几个百分点的差别。上手这种轨迹引导视频检索项目时,建议先从一段 100 小时左右的带轨迹驾驶视频开始,建立好离线评估闭环,再去追求更大规模的数据和更复杂的模型。如果文章中的实现思路对你有帮助,可以收藏备用;也欢迎在实践中多尝试不同的轨迹编码和对比学习策略,找出最适合你数据分布的那一套方案。