跳到主要内容
Video2Any
2026-08-25

0.2.0:摄像头小窗是怎么把幻灯片检测搞坏的

video-slide-extractor 0.2.0 今天发布到 npm。这个包是一个逐块比对的检测器:它按固定间隔对视频采样,然后一帧一帧地判断画面上的内容是不是换了一页。Video2Any 在浏览器里用的就是它,代码完全开源,零依赖,遵循 MIT 协议,浏览器和 Node 都能运行。

七月发布的 0.1.x 是 7 月 16 日从产品代码里切出来的一份副本。它在干净的录屏上判断正确,但面对用户手里真实的录像,有相当一部分判断是错的。真实素材里有两种情况会让它失效,产品在随后一个月里陆续解决了这两种,而这个包一直不知道。0.2.0 补的就是这两件事。

下面依次是三部分:用户上传的视频到底是什么样的,我们修改之前基准测试已经给出的结论,以及修改之后实测的数字。

本页内容

用户上传的视频是什么样的

Video2Any 从 7 月 16 日开始记录转换事件。截至今天,共有 96 位用户发起了 411 次转换,其中 354 次完成,一共导出 291 个文件:188 个 PowerPoint、95 个 PDF、8 个图片压缩包。绝大多数是本地文件——411 次转换里有 398 次,用户选的就是当前这台电脑上的视频。(我们自己跑自动化测试的那两天,已经从本文所有数字里剔除。)

有 106 次转换的文件带有时长信息,这些视频平均长 53 分钟。它们不是五分钟的产品演示,而是讲座、会议和课程录像。七月那版检测器从来没有在这类视频上测试过。

更值得看的是每次输出的张数。完成的转换平均输出 64 张幻灯片,这个平均值看上去正常,却把分布掩盖掉了:有 64 次输出超过 100 张,7 次超过 300 张,还有 4 次正好是 900 张。900 是扫描阶段允许采样的最大数量,也就是说这 4 次里每一个采样点都被当成了新的一页,检测器一张都没有筛掉。其中一次的视频只有 30 分钟。

输出速率也不是一个固定值:在同一档设置下,一小时以内的视频平均每分钟输出 6.5 张,一小时以上的只有约 1.0 张。这个差距有一部分是真实的,两小时的会议本来每分钟翻页就比短演示少。问题在于,只看张数无法判断哪一边是对的。但那 4 次触顶的转换可以判断:如果所有采样都被保留下来,那么检测器并没有在做判断。

基准测试早就给出了这个结论

在修这个问题之前,0.2.0 先往包里加了 bench/ 目录。里面的测试视频由一份已知的幻灯片序列渲染而成,所以每一次翻页发生在第几秒,是渲染时就确定的,不需要人工标注。每份讲义会渲染三个版本:一个干净版本,一个压到 crf 45、带上压缩噪声的版本,还有一个在画面角上叠了动态摄像头小窗的版本。

在 MIT 那份讲义上(46 页,每 2 秒采样一次,采样尺寸 160×90),干净版本的 F1 是 0.966。同一份讲义叠上摄像头小窗之后,F1 降到 0.538:46 页的内容抓出了 125 张,精确率只有 0.368,输出结果里有 63% 是此前已经抓过的同一页。

原因是算术上的,跟运气无关。默认的触发条件是「发生变化的块超过 2%」。摄像头小窗、鼠标指针、循环播放的动态 logo——只要一个区域在持续变化,它自己占的块就超过 2%。于是即使幻灯片一页没翻,每一个采样帧也都会越过阈值。最后能拿到多少张,取决于讲课的人有没有开摄像头,而不是这份讲义有多少页。

这一行我们照实发布了出去。前面那 4 次输出 900 张的转换,就是这一行在真实素材上的表现。0.2.0 要修的就是它。

0.2.0 新增了什么

三个新导出的函数、一个可选参数,以及它们全部的类型声明。

  • buildActivityMask

    这个函数接收采样帧,找出在几乎每一对相邻帧之间都在变化的块——摄像头小窗、鼠标指针、角上的时钟——并返回一个遮罩,把这些块完全排除在比对之外。如果遮罩会覆盖画面的大部分,函数返回 null,不返回遮罩。这一条很重要:当画面里大部分区域都在动,动的部分本身就是要看的内容,把它排除掉就等于放弃检测。

  • chooseThreshold

    同一个固定阈值没办法既适用于幻灯片,又适用于人脸特写。幻灯片本身是静止的,真正的翻页变化明显高于噪声;摄像头画面则一直在动,此时 0.02 会把每一帧都判成新的一页。这个函数会先统计帧间变化量的分布,再返回 { changedRatio, mode }。其中 mode 是特意暴露出来的:它的取值是 bimodalstaticmotiondefault,表示检测器认为自己面对的是哪一类素材。结果不对的时候,你可以直接看到它是怎么判断的,不用猜。

  • analyzeSamples

    这个函数在同一批帧上依次执行上面两步,返回 { mask, choice }。大多数调用方需要的就是它:你手上有一批采样帧,想知道该用什么参数去检测,而不必先了解「用什么参数」其实是两个互相独立的决定。

  • frameDiff({ collect: true })

    加上这个参数后,frameDiff 会额外返回 flags:每个块一个字节,标出这一帧的变化发生在哪些位置。想把检测器判定为「有变化」的区域画出来时用得上。只有传了 collect 才返回,因为一个平时根本不存在的字段,比一个平时总是 null 的字段更容易用对。

按实际调用顺序写出来,就是下面这些:

import { analyzeSamples, createSlideDetector } from 'video-slide-extractor';

// frames:RGBA 采样帧,约每 2 秒一帧,缩小到 160x90
const { mask, choice } = analyzeSamples(frames, 160, 90);
console.log(choice.mode);            // 'bimodal' | 'static' | 'motion' | 'default'

const detect = createSlideDetector(160, 90, {
  mask,
  changedRatio: choice.changedRatio
});

const kept = [];
frames.forEach((frame, i) => {
  if (detect(frame).keep) kept.push(i);   // 需要按全分辨率抓取的采样点下标
});

实测效果

测试视频和流程都不变:MIT 讲义,46 页,摄像头小窗版本,每 2 秒采样一个 160×90 的帧,检出结果与标注的翻页时间相差在 ±2.5 秒以内视为匹配。

MIT 讲义,摄像头小窗版本,真实页数 46。
检测方式抓取数精确率召回率F1重复率
0.1.x 默认值(changedRatio 0.02)1250.3681.0000.5380.632
加上活动区遮罩540.8150.9570.8800.185
加上遮罩与自动阈值351.0000.7610.8640.000

只加遮罩这一项,抓取数就从 125 张降到 54 张,重复率从 63% 降到 19%,F1 从 0.538 升到 0.880。而且它对用不上遮罩的素材没有任何影响:在干净版本和噪声版本上它返回 null,那两行结果和 0.1.1 完全一致。

阈值这一项则是有得有失,要说清楚得先看那行不好看的数据:在干净的 MIT 讲义上,自动算出的阈值比旧的固定值更保守,召回率从 0.935 降到 0.761。这正是这一版没有改动 DEFAULTS 的原因。analyzeSamples 适用于你不清楚手上素材属于哪一类的时候;如果你清楚,原来的常数都还在,仍然由你来定。

这两行新数据都是在发布对应的提交上,用基准工具跑出来的。已发布的 RESULTS.md 目前仍然只报告 0.1.x 的方法集合,把遮罩作为一种正式方法补进去,是 bench/ 下一步要做的事。

有意没做的部分

全局去重和翻页平滑不在这个包里,以后也不会加。这两件事都需要一次性看到整段视频:哪个画面是后来又回来的、哪三张抓取其实来自同一次淡入淡出。这属于流水线要处理的问题。一个只负责回答「画面变了没有」的检测器,不应该同时持有整段视频。

幻灯片密度同样不在这里:一场 90 分钟的讲座「应该」输出多少张,属于产品策略,不属于检测。Video2Any 对此有自己的判断,也提供了对应的控件。一个库不该替你做这个决定,它只需要告诉你画面在哪里发生了变化。

相关的三篇:

接下来要做的

  • 停留时长判定。 新出现的内容要先保持两秒左右不变,才算作一页。真正能消除 900 张这种结果的是它:遮罩去掉了摄像头小窗,但快速淡入淡出和拖动进度条仍然会触发。这个判定在 Video2Any 里已经生效,等接口定下来再进包——停留时长脱离采样间隔就没有意义,而这个包有意不知道调用方多久采样一次。
  • 用真实摄像头画面替换合成素材。 现在的小窗测试素材是渲染出来的动画。录一段真人出镜的摄像头画面,是更难也更接近实际的考题,遮罩应该在那种素材上测。
  • 两种方法都解决不了的一类失败。 如果相邻两页只差一行新增的要点,在 160×90 下这两帧的差异始终贴着触发下限,逐条展开的动画页因此会被整体漏掉。即使在合成重渲的素材上,基准表里也能看到这个现象。这是分辨率和评分口径的问题,docs/evaluation.md 现在把「逐条展开怎么计数」写成了必须声明、也必须报告的参数,不再由每个评测者各自理解。
  • 标定应该放在哪一层。 Video2Any 把视频拆给四个 worker,每个 worker 在自己那一段上单独标定阈值,于是同一个文件在核数不同的机器上会得到略有差别的结果,这显然不是想要的性质。看起来直接的解法是全局只标定一次。我们做了这个版本并实测:在一段 29 分钟的会议上,ffmpeg 数出 7 次真实切换,分段标定找到 6 次,全局标定只找到 4 次。全局标定并不必然更好,所以在做出一个确实更好的版本之前,这个包不会先把它当成默认。

试用

安装只有一条命令,没有任何依赖,手边任何一段录像都可以直接跑:

npm i video-slide-extractor

如果你用它做了什么,或者手上有能让它出错的素材,欢迎去仓库里提。一段能让检测器失效的视频,对我们比一个 star 更有价值——现在的摄像头小窗测试素材就是这么来的。

转换视频博客