> echo "Welcome to My Tech Zone"

$ whoami

> Tech Explorer & Code Artist

$ ls social

> GitHub

> larryxue.dev

不用AI如何提取长视频里的PPT?

背景 & 问题

做视频转PPT这件事,绕不开的核心问题只有一个:给你一段录屏,怎么判断哪一帧是"翻页了"。

搜一圈会发现,网上的做法基本就三种:

  1. 每隔N秒截一张图,10秒一张是最常见的写法
  2. 逐像素比较前后两帧,变化的像素超过某个比例就认为翻页了
  3. 更复杂的,比如场景检测模型、OCR比对文字

我维护的 video-slide-extractor 属于第二类的一个变种,用的是8×8块的平均差分。但一直以来我说"块差分比逐像素差分好",靠的是感觉,没有数字。所以我给它做了个benchmark。

结果有点出乎意料:干净素材上块差分确实赢,但在带摄像头小窗的素材上,我的默认参数精确率只有0.37,46张幻灯片抓出了125张,重复率63%。

这篇讲四件事:这个benchmark怎么设计的、三种方法的实测数字、那一档为什么会翻车、以及修完之后付出了什么代价。

难的不是算法,是没法评测

想给检测器打分,正经做法是拿标注好的切换时间点算precision/recall/F1。但手工标注一节45分钟的课,一是要花几个小时,二是标注本身有误差(渐变过程中到底哪一秒算切换?),三是别人拿不到你的标注,benchmark就没法复现。

所以我把这件事反过来做:视频不是拿来标注的,是拿已知的幻灯片序列渲染出来的。既然每一页什么时候出现是我自己定的,那每个切换时间点天然就是真值,误差为零。

两个脚本就能从零复现:

git clone https://github.com/larry-xue/video-slide-extractor
node bench/generate-fixtures.mjs   # 渲染测试视频,需要ffmpeg
node bench/run.mjs                 # 跑三种方法,生成RESULTS.md

fixtures是两个来源 × 三个变体,一共6个视频:

两个来源

  • synthetic:ffmpeg画出来的30页,不依赖任何外部素材
  • mit:MIT公开课6.0001第一讲里真实的46页幻灯片(CC BY-NC-SA 4.0,运行时从网上拉,不进仓库)

每个来源三个变体,共用同一条时间线:

  • clean:960×540,硬切,crf 23
  • noisy:crf 45 + 缩小再放大,模拟压缩噪点和模糊
  • overlay:在clean的右下角叠一个一直在动的"摄像头小窗",也就是会议录屏最典型的样子

每页停留多久,由一个固定种子的PRNG决定,范围4到12秒,所以任何一台机器渲染出来的时间线都一样。

这里有个必须说在前面的局限:这样构造出来的视频是硬切,而真实的课堂录像有渐变、有讲师逐行展开的动画、有摄像机轻微晃动,那些是另外几类失败模式,这个benchmark测不到。

三个选手

三种方法看到的输入完全一样:ffmpeg每2秒采一帧,缩到160×90的RGBA。这一步我故意冻死了,免得采样率变成隐藏变量。

方法一,定时截图。每10秒留一张,完全不看内容。

方法二,逐像素差分。一个像素的 |ΔRGB| 平均值超过25就算"变了",变了的像素超过2%就认为翻页:

let changed = 0;
for (let p = 0; p < FRAME_BYTES; p += 4) {
  const d = Math.abs(frame[p] - last[p])
          + Math.abs(frame[p + 1] - last[p + 1])
          + Math.abs(frame[p + 2] - last[p + 2]);
  if (d / 3 > 25) changed++;
}
if (changed / pixels > 0.02) { /* 翻页 */ }

方法三,块差分(我这个库)。它把画面切成8×8的块,算每个块的平均绝对差,块的平均值超过阈值才算这个块变了;变了的块超过 changedRatio,它才认为翻页:

for (let by = 0; by < rows; by++) {
  for (let bx = 0; bx < cols; bx++) {
    let sum = 0, n = 0;
    // 把这个块内所有像素的 |ΔRGB| 累加
    for (let y = y0; y < y0 + blockSize && y < height; y++) {
      let i = (y * width + x0) * 4;
      for (let x = x0; x < x0 + blockSize && x < width; x++, i += 4) {
        sum += Math.abs(a[i] - b[i])
             + Math.abs(a[i + 1] - b[i + 1])
             + Math.abs(a[i + 2] - b[i + 2]);
        n++;
      }
    }
    if (sum / (n * 3) > blockDelta) changed++;   // blockDelta 默认 14
  }
}
const ratio = changed / scored;
const isNewSlide = ratio > changedRatio;          // changedRatio 默认 0.02

块平均这一步就是全部的区别。单个像素的编码噪点在8×8=64个像素里被摊薄了,而真正的翻页会让整块整块一起亮起来。逐像素差分没有这个平均,所以噪点和真实变化在它眼里是同一种东西。

块差分我跑了两组参数:库的默认值 changedRatio: 0.02,和手动调紧的 0.10

实测结果

匹配规则:每个检测结果在±2.5秒内和一个真值切换点做1:1匹配。下面是MIT那46页的成绩,完整表格(两个来源、全部指标)在 bench/RESULTS.md

方法 clean F1 noisy F1 overlay F1 overlay精确率
定时截图(10秒) 0.675 0.675 0.675 0.757
逐像素差分(2%) 0.943 0.943 0.909 0.849
块差分(默认0.02) 0.966 0.966 0.538 0.368
块差分(0.10) 0.892 0.892 0.930 1.0

三条读得出来的结论:

定时截图是真的不行。 三个变体全是0.675,因为它压根不看内容。46页里它漏了将近四成,同时又在同一页上重复截。10秒这个数字怎么调都救不了它——调密了重复更多,调稀了漏得更多,而幻灯片的停留时间本来就不均匀。

noisy这一档没分出胜负,两种差分方法的数字和clean档一模一样。说明crf 45那点压缩噪点,对这两种方法都不构成干扰。这其实是我这个benchmark设计得不够狠,噪声变体应该再极端一些,这一格的结论目前不能采信。

overlay这一档,我的默认参数输得很难看。 精确率0.368,46页抓出125张,重复率0.632。

翻车分析

翻车的原因不复杂:右下角那个摄像头小窗一直在动,每隔2秒采样一次,它每次都在变。

只要那几个块的变化量够大,ratio 就会越过0.02这个默认阈值,于是检测器认为"翻页了",把当前帧留下来当作新的参考帧。下一次比较又是如此。结果就是同一页幻灯片被反复截了好几次。

这里有个容易骗到人的地方:这一档的召回率是1.0。所有46次真实翻页它一次都没漏。如果只看召回率,会得出"表现完美"的结论。实际情况是它把全部125个采样点里的绝大多数都留下了,所以真值当然全都被覆盖到了——当一个检测器什么都留,它的召回率必然是满分。这就是为什么必须同时看精确率和重复率。

我手动把 changedRatio 调到0.10,overlay档的精确率立刻回到1.0,F1 0.930。但代价是clean档从0.966掉到0.892——阈值调紧,那些只改了一行字的增量翻页就漏掉了。

所以一个固定的阈值,在这两种素材上没有共同的最优解。 这是我做这个benchmark最大的收获,也是默认参数该不该改的依据:不能改,因为大多数录屏没有摄像头小窗,改了是拿多数人的效果去换少数人的。真正该做的是让它自己判断。

修法:先找出一直在动的区域,再自己定阈值

库里其实另有两个函数专门处理这件事,只是benchmark为了让三种方法看到完全相同的输入,没有开它们:

buildActivityMask —— 它先扫一遍样本帧,统计每个块在多少比例的相邻帧对里发生了变化。一个块如果在超过一半的帧对里都在变,那它就不是幻灯片内容,而是摄像头小窗、鼠标、循环播放的logo动画。这些块会被整个从分子和分母里剔掉。

这里有个必须设的上限:如果要遮掉的区域超过整幅画面的35%,它就放弃遮罩。因为这时候"一直在动的东西"很可能就是画面主体本身(比如一段纯人像的视频),遮掉它等于把你要找的变化也一起遮了。

chooseThreshold —— 它把所有相邻帧对的 ratio 排序,用Otsu那套最大类间方差找一个分割点,把"同一页"和"新一页"两簇分开,分割点就是阈值。如果两簇离得不够远(后一簇均值不到前一簇的3倍),它就认为这个分割点只是噪声,退回默认值。

我把 calibrate: true 打开,在同样的fixtures上重跑了一遍,这是原benchmark里没有的一组数字:

MIT deck 捕获数 精确率 召回率 F1 重复率
overlay,默认0.02 125 0.368 1.0 0.538 0.632
overlay,开校准 35 1.0 0.761 0.864 0
clean,默认0.02 43 1.0 0.935 0.966 0
clean,开校准 35 1.0 0.761 0.864 0

遮罩只盖掉了220个块里的5个,overlay档的精确率就从0.368回到了1.0,重复率归零。

更有意思的是第二行和第四行:开了校准之后,overlay档和clean档的成绩完全一样,35张、0.864,一个数字都不差。说明遮罩确实把"带摄像头小窗的视频"重新变回了"干净视频"。

但校准不是免费的

看第三行和第四行:clean档开了校准反而变差了,F1从0.966掉到0.864,召回率从0.935掉到0.761。

我去看了它到底选了个什么阈值:0.15,正好是 calibrateThreshold 的上限

原因出在fixtures本身。这些视频是硬切渲染的,"同一页"那簇的差异接近0,"新一页"那簇的差异接近满值,两簇之间隔着一整片空白,Otsu的分割点自然落得很高,最后被上限截在0.15。而0.15意味着"必须有15%的块变了才算翻页",MIT那套幻灯片里有不少是同一页上逐行加内容的,变化量远达不到15%,于是漏掉了。

这一格的结论不能直接推广到真实录像上。 真实的课堂录像有渐变、有讲师逐行展开的动画、有摄像机的轻微晃动,两簇不会隔得这么开,分割点不会被顶到上限。但它确实说明了一件事:自动校准是在拿召回率换精确率,什么时候该换、换多少,得看素材。所以产品里在自动阈值之外,还留了一个用户能拖的灵敏度,往回拖就是把这部分召回率要回来。

关于在浏览器里跑

最后说一下工程上的约束,因为它决定了上面这些参数的取值。

这套东西是在浏览器里跑的,视频文件不上传服务器。好处很直接:用户的会议录像和课程录像不用交给别人,我也不用为存储和带宽付钱。代价是所有计算都发生在用户的机器上,而且是单线程JS。

所以采样参数是被这个约束逼出来的:160×90,每2秒一帧。一小时的录像就是1800帧,每帧56KB,差分一轮是220个块的整数运算——这个量级在浏览器里跑起来是秒级的。如果按原分辨率逐像素比,同样一小时的1080p视频要多算两个数量级,页面直接卡死。

块差分之所以合适,也有这一层原因:它在低分辨率下依然稳。降采样本身就是一次平均,和块平均是同一个方向的操作,两者叠在一起,编码噪点基本被消干净了。

小结

  • 定时截图不要再用了,它在三个变体上都是0.675,而且没有可调空间。
  • 逐像素差分不如块差分稳,差距就在块平均这一步。
  • 我的默认参数在带摄像头小窗的录屏上会翻车,精确率0.368。这不是调阈值能解决的,固定阈值在两种素材上没有共同最优解。
  • 正确的修法是先识别出一直在动的区域并遮掉它,再根据素材自己定阈值。实测精确率回到1.0,但要付召回率的代价。
  • 只看召回率会被骗。一个什么都留的检测器,召回率永远是满分。

代码和完整数据:

benchmark本身也欢迎挑刺,尤其是noisy那一档——我自己觉得那个变体设计得不够好,如果你有更接近真实场景的退化方式,欢迎提issue。

posted @ 2026-09-16 22:52  azoux  阅读(15)  评论(0)    收藏  举报