コンテンツへスキップ
Video2Any
2026-08-25

0.2.0:ワイプはスライド検出をどう壊すか

video-slide-extractor 0.2.0 を本日 npm に公開しました。このパッケージはブロック単位で画面を比較する検出器です。動画を一定間隔でサンプリングし、1フレームずつ「表示されている内容が次のスライドに変わったか」を判定します。Video2Any がブラウザ内で使っているのはこの検出器そのもので、コードは全公開、依存ゼロ、MIT ライセンス、ブラウザでも Node でも動作します。

7月に公開した 0.1.x は、7月16日時点の製品コードから切り出したものでした。きれいな画面録画では正しく判定しますが、利用者が実際に持っている録画では、かなりの割合で判定を誤ります。実際の映像には判定を狂わせる状況が2つあり、製品側はその後の1か月で両方に対処しましたが、パッケージ側は対処していませんでした。0.2.0 はその2つを取り込んだものです。

以下では、利用者がアップロードした動画が実際どのようなものだったか、修正前の時点でベンチマークが示していた結論、そして修正後に測った数値を、この順に説明します。

このページの内容

アップロードされた動画は実際どのようなものだったか

Video2Any は7月16日から変換イベントを記録しています。本日までに 96人が411回の変換を実行し、うち354回が完了、書き出したファイルは合計291件(PowerPoint 188件、PDF 95件、画像アーカイブ8件)でした。ほとんどがローカルのファイルで、411回のうち398回は、タブを開いているその端末にある動画を選んでいます。(自動テストを実行した2日分は、本記事のすべての数値から除外しています。)

尺の情報を持つファイルは106件あり、その平均は 53分でした。5分の製品デモではありません。講義、会議、講座の録画であり、7月の検出器はこの種の動画で一度も測定されていませんでした。

注目すべきは1回あたりの出力枚数です。完了した変換の平均は64枚で、この平均値は妥当に見えますが、分布を覆い隠しています。100枚を超えたものが64回、300枚を超えたものが7回、そして900枚ちょうどが4回ありました。900はスキャン時に取得できるサンプル数の上限です。つまりこの4回では、サンプル1枚ごとに新しいスライドと判定され、1枚も除外されていません。うち1本の動画は30分でした。

出力ペースも一定ではありません。同じ設定で、1時間未満の動画は毎分およそ6.5枚、1時間を超える動画は毎分およそ1.0枚でした。この差の一部は実際のもので、2時間の会議は短いデモより1分あたりのスライド切り替えが少ないのは当然です。問題は、枚数だけを見てもどちらが正しいか判断できない点にあります。ただし上限に達した4回については判断できます。すべてのサンプルを残しているなら、それは検出していないのと同じです。

ベンチマークはすでにこの結論を示していた

この問題に手を付ける前に、0.2.0 ではまず bench/ をパッケージに追加しました。テスト動画は既知のスライド列からレンダリングしているため、各切り替えが何秒目に起きるかはレンダリング時点で確定しており、人手でラベルを付ける必要がありません。各デッキは3種類レンダリングします。きれいな版、圧縮ノイズを見るために crf 45 まで落とした版、そして画面隅に動くワイプを重ねた版です。

MIT のデッキ(46枚、2秒ごとに 160×90 でサンプリング)では、きれいな版の F1 は 0.966 でした。同じデッキにワイプを重ねると F1 は 0.538 まで下がります。46枚の内容から125枚を取得し、適合率は0.368、出力の63%はすでに取得済みのスライドと同じものでした。

原因は運ではなく算数です。既定のトリガー条件は「変化したブロックが2%を超えたら」です。ワイプ、マウスカーソル、ループ再生されるロゴアニメーション——ある領域が変化し続けていれば、その領域だけで2%を超えます。そのためスライドが1枚も切り替わっていなくても、サンプリングした全フレームがしきい値を越えます。最終的に何枚取得できるかは、デッキの枚数ではなく、話し手がカメラをオンにしていたかどうかで決まってしまいます。

この行はそのまま公開しました。先ほどの900枚の4回は、この行が実際の映像に現れた姿です。0.2.0 が修正するのはこれです。

0.2.0 で追加したもの

新たにエクスポートした関数が3つ、省略可能なオプションが1つ、そしてそれらすべての型宣言です。

  • buildActivityMask

    この関数はサンプリングしたフレームを受け取り、隣接するほぼすべてのフレーム間で変化しているブロック——ワイプ、マウスカーソル、隅の時計——を特定し、それらを比較対象から完全に外すマスクを返します。マスクが画面の大部分を覆ってしまう場合は、マスクではなく null を返します。この分岐は重要です。画面の大半が動いている場合、動いている部分そのものが見るべき内容であり、それを外すことは検出をやめることと同じだからです。

  • chooseThreshold

    1つの固定しきい値で、スライドと人物の顔の両方に対応することはできません。スライドは静止しているため、実際の切り替えはノイズより明らかに大きくなります。一方カメラ映像は常に動いており、そこでは 0.02 は全フレームを新しいスライドと判定してしまいます。この関数はフレーム間の変化量の分布を調べたうえで、{ changedRatio, mode } を返します。mode は意図して公開しているもので、値は bimodalstaticmotiondefault のいずれかです。検出器がどの種類の映像だと判断したかを表すため、結果が誤っているときに判断の根拠をそのまま確認できます。

  • analyzeSamples

    この関数は同じフレーム列に対して上の2つを順に実行し、{ mask, choice } を返します。多くの呼び出し側が必要としているのはこれです。手元にサンプルがあり、どのパラメータで検出すべきかを知りたいだけで、「どのパラメータで検出するか」が実は独立した2つの判断だと知っている必要はありません。

  • frameDiff({ collect: true })

    このオプションを指定すると、frameDiffflags も返します。ブロックごとに1バイトで、そのフレームのどこが変化したかを示します。検出器が変化ありと判定した領域を描画したいときに使えます。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 のサンプルを1枚、検出結果とラベル付き切り替えの差が ±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 を返すため、その2行の結果は 0.1.1 と完全に同じです。

しきい値のほうは得失があります。それを正確に伝えるには、成績の悪い行から出すのが早いでしょう。きれいな MIT デッキでは、自動で求めたしきい値は従来の固定値より保守的になり、再現率は 0.935 から 0.761 に下がります。今回 DEFAULTS を変更しなかったのはこのためです。analyzeSamples は、渡された映像がどの種類か分からないときに使う関数です。分かっている場合は従来の定数がそのまま残っており、決めるのは呼び出し側です。

この2行はどちらも、リリース時のコミット上でベンチマークツールを使って測定しました。公開済みの RESULTS.md は現在も 0.1.x の手法セットのみを報告しています。マスクを正式な手法として追加するのが、bench/ で次に行う作業です。

意図的に入れていないもの

全体での重複統合と切り替わりの平滑化は、このパッケージには入っていませんし、今後も入れません。どちらも動画全体を一度に見る必要があります。どのカットが後から再び現れたのか、3枚の取得が実は1回のディゾルブだったのか、といった判断です。これはパイプライン側で扱う問題です。「画面が変わったかどうか」だけに答える検出器が、同時に動画全体を保持しているべきではありません。

スライドの密度も同様です。90分の講義が何枚に「なるべきか」は製品の方針であって、検出の問題ではありません。Video2Any にはその方針があり、調整用のコントロールも用意しています。ライブラリがその判断を代わりに行う必要はなく、画面のどこが変化したかを伝えれば十分です。

関連する3本:

次に取り組むこと

  • 滞留時間の判定。 新しく現れた内容が数秒とどまって初めて1枚として数える、という条件です。900枚という結果を実際になくせるのはこれです。マスクはワイプを取り除きますが、速いディゾルブやシークバーの操作は依然としてトリガーを引きます。この判定は Video2Any では既に動いており、インターフェースが固まった時点でパッケージに入れます。滞留時間はサンプリング間隔がなければ意味を持たず、このパッケージは呼び出し側がどの間隔でサンプリングしたかを意図的に知らないためです。
  • 合成素材を実写のワイプに置き換える。 現在のテスト素材は隅に配置したレンダリングのアニメーションです。実在の人物を撮ったカメラ映像のほうが難しく、実際に近い試験になります。マスクはその素材で測るべきです。
  • どちらの手法でも解けない失敗。 連続する2枚が箇条書き1行分しか違わない場合、160×90 ではその差がトリガーの下限付近に張り付き、段階的に現れるビルドがまとめて漏れます。合成で再レンダリングした素材でも、ベンチマーク上にこの現象が見えます。これは解像度と採点基準の問題であり、docs/evaluation.md では「ビルドをどう数えるか」を、必ず宣言し報告するパラメータとして定義しました。評価者ごとの解釈に委ねないためです。
  • 較正をどの層で行うか。 Video2Any は動画を4つのワーカーに分割し、各ワーカーが自分の担当区間でしきい値を較正します。そのため同じファイルでも、コア数の異なる端末では結果が少しずつ変わります。望ましい性質ではありません。素直な解決策は、全体で1回だけ較正することです。実際に作って測定しました。ffmpeg が実際の切り替えを7回数える29分の会議で、区間ごとの較正は6回、全体較正は4回しか見つけられませんでした。全体較正が必ず優れているわけではないため、確実に優れた版ができるまで、このパッケージはそれを既定にはしません。

試す

インストールはコマンド1つ、依存はありません。手元にあるどの録画でもそのまま動きます。

npm i video-slide-extractor

これを使って何か作った場合、あるいは失敗させられる映像を持っている場合は、リポジトリにお寄せください。検出器を通用しなくさせる動画は、私たちにとってスターより価値があります。現在のワイプのテスト素材も、そうして生まれたものです。

動画を変換ブログ