Video Grounding视频时序定位:从模型选型到工程落地

发布时间:2026/10/31 11:50:31
Video Grounding视频时序定位:从模型选型到工程落地

一句话从三小时素材里定位到"主播说这个价格真的不能再低了"的那一段,这活儿以前得靠人拖进度条干到眼花。

Video Grounding,中文一般叫视频时序定位或视频片段检索,干的正是这件事:丢给它一段视频和一句自然语言描述,它吐出这句话对应的时间区间,比如 00:12:34 到 00:12:51。做内容工具、素材库、长视频二创、课程切片、赛事集锦的团队,几乎都会在某个阶段撞上这个需求。它跟"给视频打标签"完全不是一个量级的活儿——打标签是告诉你这段视频里有没有足球,时序定位是告诉你足球出现在第几分几秒到第几分几秒。

这篇东西写给三类人:刚接触这个方向、想知道整条链路上哪些坑最致命的学生和转岗同学;已经在跑模型但卡在指标上不去的算法同学;以及需要判断"这块到底该自研还是调接口"的产品和工程负责人。里头所有参数、阈值、超参都是按常见实践给的一套可复现基线,具体项目还得按你的数据分布微调,但思路上能直接抄。

1. 任务定义:Video Grounding 到底在解一道什么题

1.1 一句话把任务说透,顺带划清和邻近任务的边界

形式化一点描述:输入是视频序列 V 和查询文本 Q,输出是一个时间二元组 (t_start, t_end) 以及一个置信度 s。t 的取值被约束在 [0, 视频总时长] 内。监督信号通常来自人工标注,标注的形式就是"这句话对应这段"。

听着简单,但它跟几个长相相似的任务经常被混为一谈,混错了会直接导致方案选错。

动作时序定位(Temporal Action Localization)是给定一个封闭类别集合,把视频里所有属于这些类别的动作段切出来。它没有文本查询,类别是预定义的,本质是检测任务。Video Grounding 是开放文本的,查询可以长到一句话二十几个词,描述的是"这个人从沙发上起身,拿起杯子走向厨房",句子本身就包含了主体、动作、物体、方向。

视频时刻检索(Moment Retrieval)在很多论文里和 Grounding 是同义词,区别主要在于有的工作把它处理成"从一堆候选里排序",有的处理成"直接回归边界"。前者偏检索范式,后者偏检测范式。

视频问答(Video QA)是给视频和问题,吐出答案文本。它不需要输出时间,但在可解释性要求高的时候,会把中间定位结果也吐出来,这时候两个任务就合流了。

我见过不少团队一开始把需求描述成"视频搜索",工程同学上来就搭了一个视频级的向量检索,结果业务方要的是"定位到第几秒"。视频级检索只能告诉你哪条视频相关,时序定位才能告诉你该从哪里下刀剪。这两个能力差着一整个模块。

还有一个高发的误解是把时空定位(Spatio-Temporal Grounding)和时序定位搞混。前者输出的是一个随时间变化的目标框序列,比如"穿红衣服的人"在画面里每一帧的 bounding box;后者只输出时间区间。前者的算力成本通常是后者的五到十倍,做不到位就别硬上。

1.2 任务变体:你的业务需求大概率不是最"标准"的那一种

学术上的标准设定是"一段视频 + 一句查询 → 一个时间区间"。真实业务里几乎没有这么干净的,常见的偏移有下面几种,每一种都会反过来影响你的建模选择。

第一种是一对多。运营给的一句话可能对应视频里的三个片段,比如一段口播里主播反复强调了三次同一个卖点。标准设定下模型只输出一个结果,这时候要么按 top-k 输出,要么训练时允许多个 query 匹配同一个片段(在匹配矩阵里放开约束)。我自己的处理方式是训练时保留多对多匹配,推理时取 top-1 作为主结果,同时返回 top-3 给人工挑选,实操下来业务方接受度更高。

第二种是查询里有否定和时间关系。"在展示完包装之后,主播才把产品拿出来"这句话里既有先后顺序也有动作依赖。纯 CLIP 文本编码器对这种结构是不敏感的,得靠更强的语言模型或者干脆在数据侧就把这类查询拆开。

第三种是视频长度方差极大。短视频平台的单条视频十几秒到三分钟,课程和直播回放动辄两三个小时。同一个模型喂进去,短的不够 token,长的直接爆显存。这个必须靠分窗策略解决,后头会详细说。

第四种是细粒度要求不同。找"讲到了定价策略的部分"这种粗粒度需求,容忍误差十几秒都行;找"主播说 199 这个数字的那一下",误差超过两秒就废了。前者看 IoU@0.3 就够了,后者得盯着 IoU@0.7 甚至更严。

开始动手之前,先把这四件事跟提需求的人对齐清楚,能省掉后面至少两周的返工。

1.3 指标:R@1 和 mIoU 为什么必须一起看

社区通用的一组指标长这样:

指标计算方式含义适合判断什么
R@1, IoU=0.3top-1 结果与真值的 IoU ≥ 0.3 即算命中粗定位能力是否需要"大致定位"
R@1, IoU=0.5同上,阈值 0.5中等精度主流对比基准
R@1, IoU=0.7同上,阈值 0.7精细定位剪辑类业务的核心指标
mIoU多次查询的 IoU 平均值整体重合度感受整体偏差
mAP不同 IoU 阈值下 AP 的均值综合排序质量存在多答案时的排序能力

IoU 的计算本身很朴素:

def temporal_iou(pred, gt, eps=1e-8): # pred / gt 均为 (start, end),单位秒 inter = max(0.0, min(pred[1], gt[1]) - max(pred[0], gt[0])) union = (pred[1] - pred[0]) + (gt[1] - gt[0]) - inter return inter / (union + eps)

坑在于,很多人只报 R@1 IoU=0.5 就下结论。实际情况是模型很容易学到一个"偏保守"或"偏激进"的策略:偏保守的会把区间预测得很短,IoU=0.3 过不了但 0.5 偶尔能过;偏激进的直接把整段视频当前答案,IoU=0.3 都过不了但 mIoU 不算难看。这两个指标一起看,能立刻看出模型是"定位偏了"还是"尺度偏了"。

我习惯再补一个自造指标:平均边界误差(秒),分别统计 start 和 end 的绝对误差。R@1 是阶跃的,看不出改进的连续性,边界误差能看到每次调参的细微收益,尤其适合在调参快结束时做最后一公里的判断。

2. 路线选型:三代方案摆在一起比

2.1 两阶段、单阶段、大模型三条路线各自的真实成本

这个方向的技术演进大致走了三代,每一代都不是凭空来的,都是被上一代的痛点逼出来的。

第一代是两阶段。先按滑动窗口或者动作候选生成算法切出一堆候选片段,每个片段抽特征,再用跨模态匹配给候选打分排序。这套思路的好处是解码直观、易调试,坏处是候选生成和最终目标脱节——候选阶段漏掉的片段,后头再强的排序模型也救不回来。而且滑窗的步长和窗长一换,全套效果跟着变,调参调到怀疑人生。

第二代是单阶段端到端。核心是把检测里的 DETR 范式搬到时序域:用一组可学习的 query 向量去关注视频 token,每个 query 直接预测一个区间和置信度,训练用匈牙利匹配算一对一损失。它把候选生成和匹配揉进了一个网络,端到端可训,指标上比两阶段高出一截。代价是收敛慢,需要长训练周期和合适的学习率调度。

第三代是多模态大模型路线。把视频按固定 fps 抽帧,喂给视觉语言模型,让模型直接用自然语言吐时间戳或者特殊 token。这条路的天花板最高,能处理"展示完包装之后再拿产品"这种带逻辑关系的查询,但推理成本高得离谱,一个两小时的视频跑一遍可能要几十秒到几分钟。真正上生产,通常是拿它做离线的数据蒸馏或者给难例做兜底。

把三条路线的工程成本摆在一起看会比较清楚:

维度两阶段单阶段 DETR 式大模型直接生成
训练成本低中高(需要多卡)
推理延迟(10 分钟视频)约 100-300ms约 150-400ms数秒到数十秒
长尾语义理解弱中强
调试难度低,中间结果可见中,匹配过程需可视化高,黑盒
细粒度定位中较好一般(时间戳 token 化有精度损失)
适合场景快速起量验证主力生产模型离线难例、数据生产

我的判断逻辑很简单:如果业务刚起步,样本量在几万条以内,先用两阶段的思路搭一个能跑的基线,两周内拿到可用指标,验证需求真实存在;需求确认后,主力模型切到单阶段;大模型放在离线侧,专门处理线上 badcase 和生成伪标注。

2.2 我为什么把检索前置 + DETR 式单阶段作为主干

纯单阶段有个绕不开的问题:候选 query 数量是固定的,比如设 32 个,那视频里如果有超过 32 个相关片段就漏了。而且 query 是在整个视频上做注意力,视频长度从 30 秒到 2 小时,token 数差两个数量级,训练时不同 batch 的难度分布极不均匀。

我最终采用的组合是"检索前置 + 单阶段精定位"。第一层用便宜的整段视频级特征和文本特征做相似度,把候选范围从几万条视频收窄到几十条,同时把每条视频内部用粗粒度窗口(比如 30 秒一段)再筛一遍,留下 top-k 窗口。第二层把这些窗口拼起来送进 DETR 式模型做精确回归。

这个设计的收益不只是速度。因为第二层看到的都是"大概率相关"的窗口,正样本密度大幅提高,训练时匹配的稳定性好很多。实测下来,同样数据量下这条链路比纯单阶段在 IoU=0.5 上高出几个点,长视频上的差距更明显。

代价是多了一层,召回层漏掉的永远救不回来。所以召回层的 top-k 我一般设得比较宽松,宁可多召回 3 倍,把压力交给精排。

2.3 特征层选型:fps、分辨率、视觉编码器怎么定

这一层的选型对最终效果的影响,比换模型结构大得多。我踩过的最大一个坑就是 fps 设太低。

视觉编码器用图文对比预训练那类模型是当下的主流选择,它把每帧编码成一个 512 到 768 维的向量,天然和文本空间对齐,跨模态匹配的起点就好。但它的一个特性必须知道:这类模型是按图像训练的,对帧间运动不敏感。如果查询里有"快速跑过""慢慢转身"这类强运动语义,纯帧特征会丢信息。

我的做法是双路特征并置。一路是图文预训练模型的帧特征,负责物体和场景语义;另一路是轻量的动作识别骨干网络在较低分辨率下抽的视频特征,负责运动。两路特征在通道维拼接后过一层线性降维,把维度拉回到 512 左右。这个小改动在包含运动描述的测试集上值好几个点。

fps 的选择有个经验公式:目标事件的最短持续时长决定了最低 fps。如果最短事件是 2 秒,那么 2fps 只能采到 4 帧,边界分辨率就是 0.5 秒,R@1 IoU=0.7 基本没戏。我的实践是让单帧时长不超过 0.25 到 0.5 秒,也就是 2 到 4 fps。再高收益递减,特征存储成本线性上涨。

分辨率也一样。224 是性价比最高的点,336 在需要读文字(比如画面上的字幕、商品标签)时值得上,再高就要掂量存储了。这里有个重要的作弊技巧:如果视频里有大量语音信息,把 ASR 转写按词级时间戳对齐叠进去,往往比提高视觉分辨率有效得多。我做过一个对比,同样算力预算下,加 ASR 文本通道带来的提升是分辨率从 224 提到 448 的两倍以上。

特征存储成本算一笔账就很清楚:一段 60 分钟的视频,2fps 抽帧是 7200 帧,每帧 512 维 float16 是 1KB,合计约 7MB。十万条这样的视频就是 700GB。如果改到 4fps,直接翻倍到 1.4TB。所以生产环境里我一般用 float16 存储,并且把特征按需缓存到对象存储,走 LRU 淘汰。

3. 数据与特征:脏活决定天花板

3.1 标注清洗的几条硬规则

这一节讲的都是"模型再好也救不回来"的事。

标注质量的第一杀手是边界歧义。同一句话,十个人标出来的起点可能差三秒。我的处理方式是定一条死规则:边界以"语义内容开始呈现"的那一刻为准,而不是"动作物理开始"的那一刻。比如"主播拿起产品介绍"这句,如果画面上手伸进画面是在 10.0 秒,产品进入视线是在 10.5 秒,就在标注规范里明确写死取 10.5。规则本身是什么不重要,重要的是所有人一致。

第二条是重叠处理。两个查询如果标的区间 IoU 超过 0.8,要么合并要么改描述,不要留着。这类样本在训练时会让匹配矩阵出现平局,损失函数的梯度方向变得含糊。

第三条是长度分布检查。把标注的区间长度画个直方图,如果超过一半的标注长度都接近视频全长的 0.8 以上,说明标注人员偷懒了(直接把整段标上),这批数据基本是噪声。健康的数据应该是长度分布广、中位数在视频总长的 20% 到 40% 左右。

第四条是文本长度。查询太短(三五个词)时跨模态信息量不足,"打开盒子"这句话在视频里可能对应七八个不同的片段。我的经验是让训练集的查询平均长度落在 12 到 25 个词之间,太短的补充上下文,太长的(超过 40 词)拆成两句。

统一后的标注格式我一般用 JSONL,一行一条:

{"video_id": "v000123", "duration": 312.4, "query": "the person stands up and walks toward the kitchen", "t_start": 45.2, "t_end": 53.9, "fps": 2.0, "split": "train"}

注意 duration 一定要存原始视频时长,别存抽帧后的时长。我用过一份别人整理的数据,duration 字段填的是帧数,结果所有时间戳都错得离谱,排查了一下午才发现。

3.2 抽帧与特征缓存的实现

抽帧这步看着最简单,其实最容易出问题。核心要求是时间戳可复现:给定 fps=2,第 k 帧对应的时间必须是 k/2 秒,不能有累积漂移。

用 ffmpeg 直接指定 fps 滤镜是最稳的做法:

ffmpeg -hide_banner -loglevel error \ -i input.mp4 \ -vf "fps=2,scale=224:-2" \ -q:v 3 \ frames/%06d.jpg

scale=224:-2里的-2是让高度按比例自动计算并保证偶数,别写成-1,某些编码器对奇数尺寸会报错。

批量处理时我更倾向于用 Python 起子进程,方便并发控制和错误重试:

import subprocess, os from concurrent.futures import ThreadPoolExecutor def extract(video_path, out_dir, fps=2, size=224): os.makedirs(out_dir, exist_ok=True) cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", video_path, "-vf", f"fps={fps},scale={size}:-2", "-q:v", "3", os.path.join(out_dir, "%06d.jpg"), ] r = subprocess.run(cmd, capture_output=True, timeout=600) return r.returncode == 0 with ThreadPoolExecutor(max_workers=8) as ex: results = list(ex.map(lambda p: extract(p, cache_dir(p)), video_list))

并发数别开太大。ffmpeg 解码是 CPU 密集的,开到 8 到 16 一般就吃满机器了,再高反而因为磁盘 IO 争抢变慢。我之前在 32 核机器上开到 32,整体耗时比开 12 还长了一点。

特征提取后的存储有两条路:一是存成 float16 的 npy,一条视频一个文件;二是统一灌进 HDF5 或者 webdataset 形式的 tar 分片。前者方便调试,后者训练时随机读效率高得多。数据量过十万条之后建议直接上分片格式,小文件数量太多会把 inode 和各种同步工具搞崩。

这里有个细节值得单独提:抽帧和特征提取要解耦。抽帧的结果(jpg 序列)可以随时重抽,特征提取的结果依赖具体模型版本。一旦你升级了视觉编码器,所有特征都要重算。所以缓存的目录结构里一定要带模型标识,比如features/clip_vitb16_f2/{video_id}.npy,不然就会出现新旧特征混用,导致评估结果莫名其妙地抖动。

3.3 负样本和时序增强

正负样本构造是这类任务里被严重低估的环节。

跨模态对齐本质是个对比学习问题。如果每个 batch 里只有正样本对,模型学不到"什么样的文本不该匹配这段视频"。我的做法是每个 batch 里同时构造两种负样本:一种是视频内负样本,同一视频里随机采一段和查询不重叠的区间(IoU 小于 0.1);另一种是跨视频负样本,用同一 batch 里其他视频的查询做负例。两种各占一半,效果比只用一种好。

难度挖掘也有讲究。随机采的负样本太容易了,模型几百步就学会。我一般会定期用当前模型跑一遍训练集,把那些"模型预测得分很高但 IoU 很低"的样本挑出来,作为额外负样本加入下一轮训练。这个流程手工跑个两三轮回合就够了,不用搞成复杂的在线机制。

时序增强方面,能用的手段比图像少得多,因为破坏时间结构就破坏了标签。几条能用的:

  • 时间裁剪:随机裁掉首尾 10% 到 30%,同步调整标注时间戳,模拟视频被剪过的情况。
  • 速度扰动:以 0.9 到 1.1 的倍率重采样时间轴,标注按倍率同步缩放。这条能提升模型对时长估计的鲁棒性。
  • 帧丢弃:随机丢掉 5% 到 10% 的帧,模拟解码丢帧。这个在线上真实发生过——某些编码的视频用硬件解码器会固定丢帧。

不能用的:随机翻转(视频没有水平翻转语义)、时间反转("走进去"反转后语义不成立)、大幅调色(会破坏跨模态对齐)。我试过时间反转,指标掉了将近 5 个点,纯粹是给自己找麻烦。

4. 模型与训练:从能跑通到能上榜

4.1 跨模态编码与多尺度 token

整体结构分三块:视觉编码、文本编码、跨模态融合与预测头。

视频侧拿到的是(T, D)的帧级特征矩阵,T 可能上千。要做的第一件事是降采样到可控的 token 数,比如 128 或 256。这里别用平均池化,平均池化会把边界糊掉。我用的是可学习的一维卷积加步长下采样,让模型自己决定怎么聚合。多尺度也有必要,同时保留 1 倍、2 倍、4 倍下采样的三个分支,最后拼接,这样既有粗粒度语义又有细粒度边界信息。

文本侧直接用预训练语言模型的输出,取最后一层的 token 序列而不是只取 [CLS],因为细粒度定位很大程度依赖词级别的对齐信息。

融合层我偏向早期融合加交叉注意力的组合:先把文本 token 和视觉 token 投影到同一维度,拼接后送进若干层 Transformer 编码器,让它自己做跨模态注意力;然后接一个轻量的跨模态解码器,解码器的输入是可学习的 query 向量。

预测头的设计有个关键选择——参数化方式。直接预测 (start, end) 两个绝对坐标的问题是边界约束难保证、梯度不稳定。我用中心点加宽度的参数化:

class PredHead(nn.Module): def __init__(self, d_model=256): super().__init__() self.cls = nn.Linear(d_model, 1) self.conf = nn.Linear(d_model, 1) self.reg = nn.Sequential( nn.Linear(d_model, d_model // 2), nn.GELU(), nn.Linear(d_model // 2, 2), # 输出 (center, width) 的 logits ) def forward(self, x): # x: (B, N_q, d_model),N_q 为 query 数量 cls_logit = self.cls(x).squeeze(-1) # (B, N_q) conf_logit = self.conf(x).squeeze(-1) # (B, N_q) center_w = torch.sigmoid(self.reg(x)) # (B, N_q, 2) center, width = center_w[..., 0], center_w[..., 1] start = (center - width / 2).clamp(0.0, 1.0) end = (center + width / 2).clamp(0.0, 1.0) return cls_logit, conf_logit, start, end

中心加宽度的好处是宽度和中心解耦,模型可以独立学"这段有多长"和"这段在哪"。代价是 start 和 end 会耦合,极端宽度会同时推两边。实测下来这个参数化比直接回归端点在 IoU=0.7 上更稳,训练早期的震荡也小。

4.2 匈牙利匹配与损失配比

DETR 类结构必须配对,配对用的是匈牙利算法最小化总代价。代价矩阵由三部分构成:分类代价、L1 回归代价、IoU 代价。

from scipy.optimize import linear_sum_assignment import torch def build_cost(pred_cls, pred_box, gt_box, w_l1=5.0, w_iou=2.0, w_cls=1.0): # pred_cls: (N_q,),已过 sigmoid;pred_box: (N_q, 2);gt_box: (M, 2) n_q, m = pred_cls.shape[0], gt_box.shape[0] cost = torch.zeros(n_q, m) for i in range(n_q): inter = torch.clamp( torch.min(pred_box[i, 1], gt_box[:, 1]) - torch.max(pred_box[i, 0], gt_box[:, 0]), min=0.0, ) union = (pred_box[i, 1] - pred_box[i, 0]) + (gt_box[:, 1] - gt_box[:, 0]) - inter iou = inter / (union + 1e-8) l1 = (pred_box[i].unsqueeze(0) - gt_box).abs().sum(dim=1) cost[i] = w_cls * (-pred_cls[i]) + w_l1 * l1 + w_iou * (-iou) rows, cols = linear_sum_assignment(cost.detach().cpu().numpy()) return torch.as_tensor(rows), torch.as_tensor(cols)

配完对之后算损失。分类损失我用 focal loss,因为前景 query 数量远少于背景;回归损失用 L1 加广义 IoU 的组合,L1 管坐标精度,GIoU 管整体重合度。两者的权重我一般设成 1:1,但视频任务里我倾向于把 GIoU 的权重再加一点,因为业务方对"整体重合"的感知强于对单个端点的感知。

还有一路置信度分支,它的监督信号来自匹配到的那个 query 和真值的 IoU 值本身,用平滑 L1 回归。这路分支在推理时非常有用,它输出的不是"这是个目标的概率",而是"我认为这次定位准不准",做阈值过滤和排序时比分类分数好用得多。

4.3 损失配比与超参表

把我反复调过之后比较稳的一套配置列出来,都是在单机八卡、视频 token 数 128 的设定下:

项目取值说明
优化器AdamWbeta 取默认 0.9/0.999
视觉骨干学习率1e-5预训练权重,别动太大
新增模块学习率1e-4融合层和预测头
权重衰减1e-4骨干部分可以放宽到 0
批大小32按显存调整,最低 8 也能跑
训练轮数100-150DETR 式结构收敛慢,别早停太狠
warmup总步数的 5%前 500 步左右
学习率调度余弦退火到 1e-6最后 10% 步数压到接近零
query 数量32大多数视频片段数不超过这个量级
视频 token 数128长视频分窗后凑到这个数
混合精度开启回归部分保持 fp32 更稳
梯度裁剪1.0匈牙利匹配早期梯度容易爆

两组学习率是必须的。骨干网络是在海量数据上预训练的,用 1e-4 去更新会很快把预训练知识冲掉,前十几个 epoch 指标可能还看着不错,之后就开始灾难性遗忘。我早期就是一根 1e-4 走到底,训练集损失降得飞快,验证集卡在原地,花了整整两天才反应过来是学习率的问题。

warmup 对这套结构同样关键。匈牙利匹配在训练初始阶段完全是随机的配对,梯度方向噪声极大。不做 warmup 的话,前一两千步经常出现 loss 曲线突然上翘。这个现象我当时以为是数据里有脏样本,抽查了半天才确认是优化问题。

4.4 显存和吞吐优化

长视频是显存杀手。假设每帧 512 维,2fps,一个两小时的视频就是 14400 帧,光是原始特征就 14MB,过完编码器后 token 化到 128 个还好,但如果做全局注意力一下子就顶到几十 GB。

几条实际有用的手段:

第一,训练时严格按时间窗采样。每个样本随机采一个 64 到 256 帧的窗口,保证窗口内至少覆盖一个完整正样本。这样既控制了长度方差,又让正样本密度保持在合理区间。评估时再走完整的分窗拼接流程。

第二,token 降采样的步长跟窗口长度挂钩。短窗口用 1 倍(不降),长窗口用 4 倍,保证最终 token 数落在 128 附近。动态步长的实现很简单,就是根据输入长度算一个整数倍,用自适应平均池化或者带步长的卷积。

第三,推理时的分窗要带重叠。我用窗长 128 帧、重叠 32 帧(25% 重叠)。重叠区域的结果合并有两种做法:和我遇到的大部分情况一样,交叉窗的预测如果 IoU 大于 0.5 就合并取加权平均;不重叠的直接保留。权重按离窗中心的距离给,离中心越近权重越高,因为边界处的注意力本来就弱。

5. 推理与工程落地

5.1 在线服务两段式

线上链路我拆成四个串行环节,每一环的耗时和失败率都要单独监控。

第一环是查询理解。文本进来先做规范化:去停用词冗余、统一大小写、数字归一("一百九十九"转成 199)。这一步对检索召回率的影响不小,特别是包含数字和专有名词的查询。加一个轻量的同义词扩展,把"便宜""低价""打折"映射到同一个语义簇里。

第二环是视频召回。每个视频预存一个整段级别的特征向量(对帧特征做注意力加权池化,别用简单平均),和查询向量算余弦相似度,取 top-100。这一步要在毫秒级完成,用向量索引库的近似检索就够了,精度损失可以接受。

第三环是窗口级粗筛。对召回的每条视频,把它切成 30 秒左右的窗口,每个窗口算一个相似度分,取 top-8 个窗口。

第四环才是精定位模型。把粗筛出来的窗口按时序拼接送回模型,拿到归一化的 (start, end),映射回绝对时间,再按置信度过滤。

整套下来,十万条视频的库,一次查询的端到端延迟能控制在 300 毫秒以内,其中精定位模型占了将近七成。如果延迟不达标,优先砍的是精定位模型的输入长度,其次才是减少召回数量。

5.2 后处理与阈值标定

模型的原始输出不能直接用,几件事必须做。

时间对齐:模型预测的是归一化坐标,映射回绝对时间时要乘的是窗口时长而不是视频总时长。这个错误我见过不止一次,写的时候抄了一下没改,结果所有结果都偏了几十倍。

长度过滤:预测区间短于 0.5 秒的,基本是噪声,直接丢。长于视频总长 90% 的,也要先怀疑是不是回归塌陷了。

NMS:同一视频输出多个候选时要去重。

def temporal_nms(segs, scores, iou_thr=0.6): order = sorted(range(len(scores)), key=lambda i: -scores[i]) keep = [] while order: i = order.pop(0) keep.append(i) rest = [] for j in order: inter = max(0.0, min(segs[i][1], segs[j][1]) - max(segs[i][0], segs[j][0])) union = (segs[i][1] - segs[i][0]) + (segs[j][1] - segs[j][0]) - inter if inter / (union + 1e-8) < iou_thr: rest.append(j) order = rest return keep

阈值标定是最容易被忽略的一步。验证集上把置信度从 0 扫到 1,画 P-R 曲线,找到业务最关心的那个操作点。如果业务是"人工二次筛选",召回优先,阈值就往低了压,宁可多给十条让运营挑;如果是"全自动剪辑",准确率优先,阈值就要抬高,漏掉的总比剪错强。这两个场景的最优阈值可能差 0.3 以上,别拿一套阈值打天下。

5.3 效果评估与 badcase 归因

离线指标涨了不代表线上就好了。我固定的做法是每周抽 100 条线上真实查询,人工标一遍,看三个数:命中率、平均边界误差、空结果率。

badcase 归因我按下面这张表分类,每周统计各类占比,占比最高的那类就是下一周的重点:

现象常见根因处理方向
定位到大致的区域但边界差很多fps 太低,边界分辨率不足提高采样帧率或加边界回归精修头
完全定位到错误位置检索层召回错了,或者文本语义没理解检查召回结果,补文本侧数据
输出整段视频回归塌陷,模型退化成偏保守策略检查回归损失权重和 GIoU 实现
输出极短片段置信度分支过拟合,倾向短区间补长区间样本,调长度先验
同一查询多次结果不一致分窗重叠区合并逻辑有 bug检查合并权重和时间映射
长视频效果明显差分窗后跨窗语义断裂加跨窗全局上下文或重叠加大
含数字/专有名词的查询失效文本 tokenizer 把数字切碎了加数字归一化预处理

有个细节我印象很深:含数字的查询长期效果差,一直以为是模型学不会数字对应关系,后来发现是分词器把 "199" 切成了几个子词片段,模型很难把它们当整体。在预处理里把数字转成统一格式(比如全角转半角、中文数字转阿拉伯数字),再加一条正则把数字前后的空格规范一下,这类查询的命中率提升了接近十个百分点,改动量不到二十行代码。

6. 常见问题排查实录

6.1 训练能跑但指标不涨

最常见的状态是训练损失稳定下降,验证集 R@1 IoU=0.5 卡在某个值再也不动,比如 42% 左右晃。

第一个要查的是数据泄漏。检查训练集和验证集是不是按视频划分的。如果同一个视频的不同查询被分到了训练和验证两侧,指标会虚高,后期涨不动。这种划分错误极其常见,因为很多人按"标注条目"随机切分,而一个视频往往有多条标注。

第二个要查的是匹配的稳定性。把匈牙利匹配的结果打出来,看每个 batch 中有多少 query 被匹配上了。正常情况下这个比例应该在 0.3 到 0.6 之间浮动。如果长期低于 0.2,说明大部分真值没被任何 query 匹配上,模型在学背景。这时候要么增加 query 数量,要么检查代价矩阵的权重配比。

第三个要查的是回归分支的输出分布。把预测的区间长度画成直方图,和真值的长度直方图对比。如果预测的长度集中在某一个很窄的范围(比如全都在 0.1 附近),那是典型的模式塌陷,说明模型学会了"猜一个平均值"这种偷懒策略。

6.2 长视频与跨窗问题

长视频上的定位错误有一个非常特定的形态:预测的区间是正确的,但整体偏移了一个窗长。基本可以确定是时间映射的 bug——归一化坐标乘的是窗口时长,加偏移时用的是窗起始时间,只要有一处漏了就整体偏移。

另一种形态是区间跨越了两个窗。比如真值从 3:58 到 4:06,而分窗边界是 4:00,模型在第一个窗里只能看到前半段,第二个窗里只能看到后半段,两边各预测一部分,合并后 IoU 就很低。

我的解法是两个改动叠加。一是把重叠率从 25% 提到 50%,让大部分跨窗事件至少在一个窗里完整出现。二是允许模型预测"越界",也就是不把 start 和 end 硬性 clamp 在 [0, 1] 内,允许到 [-0.2, 1.2],合并时再统一裁掉。第二个改动听起来很怪,但实测有效,因为模型能表达"这个东西从窗外面开始"。代价是要在数据构造时对部分窗口内的样本做特殊处理,让越界预测有监督信号。

6.3 上线后指标对不上离线

离线涨点、线上不动或者掉了,我遇到过的原因按出现频率排:

特征版本不一致。离线和线上用了不同版本的视觉编码器,或者同一版本但在不同的图像预处理参数下抽的特征(比如归一化的均值方差不一样)。这个最隐蔽,因为两边的特征维度完全一样,不会报错,只会让效果变差。

视频预处理不一致。线下处理时用的是原始视频文件,线上是转码后的。转码会改变帧数、时长,如果时间戳是基于原始视频算的,映射到转码后的视频上就整体偏移了。解决办法是统一用转码后的标准版本做特征提取,或者记录转码前后的时间映射关系。

查询分布漂移。模型是在某个特定类型的查询上训的,线上真实查询的表达方式完全不同。这个只能靠持续收集线上查询、定期补数据解决。我的习惯是每周抽一批线上查询,人工标注后加入训练集,保持每季度一次小规模迭代。

阈值没重标。模型换了但推理阈值还是旧的。模型输出分布的绝对值会随训练变化,换了模型必须重新扫一遍阈值。

7. 一些不太好归类但值得记下来的经验

7.1 别急着上大模型,先把评估集做扎实

我见过太多团队在评估集上省事,结果后面所有的对比都不成立。一个规范的评估集至少要满足三条:规模够(每条任务对应不同视频,总量不低于 500 条)、分布贴近线上(查询长度、视频时长、业务类型都要覆盖)、标注双人交叉验证(有分歧的第三条仲裁)。

这件事花的时间是值得的。评估集不准,你后面每一次"涨点了"都可能是噪声。我自己踩过的坑是拿一个只有 80 条样本的验证集调了两周超参,最后发现这些超参在扩充到 800 条的验证集上毫无区别。

7.2 特征缓存的失效策略要想清楚

特征缓存是省时间的关键,但它也是版本混乱的重灾区。我的做法是把缓存 key 设计成{video_id}_{encoder_name}_{fps}_{resolution}_{preprocess_version}的哈希,任何一环变了 key 就变。同时维护一个清单文件,记录每批缓存是用什么配置生成的。这套机制看起来笨,但救过我好几次——有一次升级编码器之后忘了清缓存,指标不升反降,靠清单文件十分钟就定位到了问题。

提示:如果你的缓存 key 里只带了 video_id,那基本就是在等一次事故。

7.3 关于是否要用多模态大模型做蒸馏

这个思路我自己实践过,效果是有的但要算清楚成本。具体的做法是拿大模型在一批难样本上跑出预测,把置信度高的作为额外标注混进训练集。我做过一次,训练集的规模从 5 万条扩到 12 万条,主模型在 IoU=0.5 上提升了差不多 3 个点。

要注意的是筛选门槛要严。大模型的输出即使看起来合理,也可能整体偏移,如果筛得太松反而引入系统性偏差。我的筛选条件是:大模型预测的区间和已有标注的 IoU 大于 0.3,或者这条样本原本没有标注但大模型连续两次预测的结果高度一致(IoU 大于 0.8)。后一条能过滤掉大部分随机输出。

还有一个隐性成本是这类蒸馏会让模型学到大模型的软偏见,比如它会系统性地偏好更长的区间。如果业务对边界精度敏感,这个偏差得在最后用一批纯人工标注数据做微调来压掉。

7.4 关于端到端可解释性

DETR 式结构的可解释性是它的短板,你很难说明它为什么给出这个区间。我的做法是加了两个辅助手段:一是把跨模态注意力的分布可视化出来,看文本 token 主要关注了哪些时间段的视觉 token,这个能快速判断模型是不是在看正确的地方;二是把置信度分支的输出和实际 IoU 做一个散点图,看两者是否单调相关。如果相关性很弱,说明置信度分支基本没用,做阈值过滤时就是在瞎猜。

这两个手段在排查"模型为什么定位错了"的时候非常有效。有一次发现所有含"拿起"这个动词的查询都定位偏后,可视化注意力才发现模型把注意力全放在了手已经拿起来之后的那几帧,完全是数据标注里这类动作的边界定义不一致导致的。

我自己在这个方向上做了两年多,最大的体会是:模型结构带来的收益是有限的,每一代新结构最多带来几个点;而数据处理、评估体系和特征质量带来的收益是复利的,改动一次长期受益。所以与其追最新的论文结构,不如先把抽帧的 fps 定对、把标注规范写死、把评估集做扎实、把缓存版本管好。这几件事做完,剩下的调结构才有意义。