スライドがメインのプレゼンテーション動画から静止画切り出しを行い、なおかつ内容が重複する静止画を自動的に削除する

# スライド資料がメインのプレゼンテーション動画はスライドが別途欲しいけれど、動画しか手に入らなかったら？
スライド資料がメインのプレゼンテーション動画はスライド資料も欲しいのですけれど、動画しか手に入らない場合も多々あるかと思います。そういった場合に動画から静止画を切り出すことでスライド資料を無理やり作ろう、というのがこの記事の趣旨です。


# 前提環境

- 必須パッケージは ffmpeg と ImageMagick です。
- 切り出した画像を png で保存したい場合は pngquant もインストールしてください。
- Ubuntu 16.04LTS、Kali Linux 2016.2、CentOS7、Bash on Windows (Anniversary Update)、macOS Sierra で動作確認しました。
- CentOS7については、nux-desktop リポジトリからインストールした ffmpeg での動作を確認しました。
- Ubuntu 14.04LTS の libav に含まれる avconv コマンドでも利用できます。 ただしこの場合は alias ffmpeg=avconv するか、またはスクリプト内で ffmpeg を実行している箇所を avconv に書き換えてください。
- Bash on Windows については Anniversary Update 上の14.04LTSでの avconv で動作確認しました。Creators Update は未検証ですが 16.04LTS ベースだから ffmpeg をインストールすれば普通に使えるはずです。
- macOS Sierra は brew で ffmpeg と imagemagick をインストールした環境で動作確認しました。

# 基本的なアイデア
## 動画からの静止画切り出しは ffmpeg で OK 

ffmpeg には動画から静止画を切り出す機能がありますので、その機能を使うだけです。簡単ですよね。

今回は以下のように実行して静止画を抜き出すことにします。RATE=1なら毎秒1枚、RATE=0.1なら10秒ごとに1枚の静止画を抜き出せます。

```bash
ffmpeg -i "動画ファイル名" -r ${RATE} slide%d.png
```

## 重複する静止画を削除するには、２枚の静止画の PSNR で類似性を判断してみる

MPEG等の不可逆圧縮なコーデックの動画から切り出した静止画は、人間が判断する分には論理的に同じ意味の内容であっても、データを比べると一致することはまずありません。しかし ffmpeg で一定時間ごとに切り出した画像には論理的に重複する内容が含まれてしまいます。もし、人間が判断すれば論理的に等価な画像を捨てることができれば、必要な画像枚数が減らせます。これをロジックで自動的に行うために、今回は２枚の画像の PSNR を算出することを試みます。PSNRは2つの画像を比較したときにどの程度劣化しているかを判断する指標ですが、劣化度合いがわかるということは画像が論理的に等価かどうかの判断に使うこともできます。この数値は大きいほど劣化が少ない、つまり論理的に等価の可能性が高いことになります。なお、PSNR についての詳細な解説はここでは省略しますが、Wikipedia の以下のエントリとかを読んでみてください。

https://ja.wikipedia.org/wiki/%E3%83%94%E3%83%BC%E3%82%AF%E4%BF%A1%E5%8F%B7%E5%AF%BE%E9%9B%91%E9%9F%B3%E6%AF%94

また、画像の類似性の比較にはSSIMなどの別の指標もあります。このあたりの話に興味を持たれた方は、こちらの記事も読んでみるとよいのではと思います。
[MSE/PSNR vs SSIM の比較画像紹介]
(https://qiita.com/yoya/items/510043d836c9f2f0fe2f)

PSNR の値から**「PSNRの値が大きいと、２枚の画像は同一の内容である」「PSNRの値が小さいなら、２枚の画像は別の内容である」と切り分けできるならば、あとはしきい値の決め方の問題になりますが、**今回の実装ではこのしきい値を25dBにすることにします。つまり**「２枚の画像の PSNR が 25dB を超える場合は同一画像だから削除可能」と判断**するように実装してみます。25dBをしきい値とする理由ですが、不可逆なコーデックでの PSNR の標準的な値は 30dB 〜 50dB と言われているようので、それよりも悪い（数値が低い）場合を除外することを意図しています。

PSNR を求める方法ですが、これは ImageMagick に含まれる compare コマンドが利用できます。以下のように実行すれば OK です。

```bash
compare -metric PSNR [画像1].png [画像2].png diff.png
```

そうすると標準出力に対して PSNR の値が出力されます。なお diff.png には２枚の画像の差分情報が出力されています。

ちなみに完全に同一な画像の PSNR はゼロ除算となるので計算できません。このときの compare の出力は ImageMagick のバージョンによって 0 または inf となるようです。このように同じ操作のアウトプットにゆらぎが出るのはありがたくないので、PSNR を算出する前にバイナリデータの diff をとることにします。画像ファイルのバイナリデータの diff を取るのは軽い処理ですが、PSNR を算出するのはそれなりにCPUリソースを消費するので、このような実装にするほうが処理が軽くなります。これにより、完全に同一な画像では PSNR を計算する必要がなくなります。

## マウスカーソル等は median で消す

重複する画像は単に削除してもよいのですが、それらの画像を合成して median(中央値) をとれば、マウスカーソル等のノイズを消し込むことができます。この方法は、固定カメラで撮影した動画から人や車を消し去るための方法の一つとして使われるものです。

median は以下のように実行すれば処理可能です。

```
convert -evaluate-sequence median 画像a 画像b ... 画像n 出力画像ファイル
```

ただし計算にはCPUリソースを消費します。

median の代わりに mean(平均値) を指定すればCPUリソースは消費しないのですが、対象画像数が増えるとメモリの消費量が増えますし、スライド画像の消込に対する効果は mean ではイマイチのような気がしましたので、今回は median を用いています。

# 保存する画像フォーマットを選べるようにし、できれば軽くする

PNG で切り出した画像はそのままだとあまりファイルサイズが小さくならないので、以下のどちらかで保存できるようにします。

- JPEG
- PNG (pngquant で軽量化する)

# ファイル名の連番を時刻表記に変更する

ffmpeg で切り出した状態だと slide.217.png のように連番をつけて保存しますが、これではこの場面の再生位置がわかりづらいため、対象部分を視聴しなおしたいときに不便です。このため、slide.00.03.37.png のように再生位置の時刻表記で示すように rename します。

# 実装
上記の内容を元に実装したのがこちらの内容です。なお、本記事の趣旨とは若干異なりますが、重複する内容の画像の削除を望まない場合は、ffmpeg 実行後の exit を有効にすればOKです。

```bash:movie2slide.sh
#!/bin/bash

# スライド中心のプレゼンテーション動画から
# スライド画像を切り出すスクリプト
#
# このスクリプトは以下のことを行います
#
# FFmpeg で動画を1秒に1枚づつ PNG 画像に変換する
# 変換したPNG画像のうち時系列で重複したスライドを削除する
#
# 内容の重複検出には枚の画像の PSNR が一定のしきい値を超えるかどうかで判定します

for command in ffmpeg convert diff
do
    which $command
    if [ $? != 0 ]; then
        echo "$command がありません。処理を停止します。"
        exit 1
    fi    
done

# PSNR がこの値を超える場合は同一画像とみなす
PSNR_threadshold=30

# 類似フレームの消し方
# 指定すると類似フレームに対して指定の演算を行った結果を画像として残し、他は全て消す。
# 未設定の場合は類似フレームの最初の画像だけを残して、他の類似フレームを単純に消す。

#SEQUENCE=median    # スライドだけの資料でマウスカーソルを極力消したい場合
#SEQUENCE=max
#SEQUENCE=min

# 出力ファイル名のprefix
# ファイル名は最終的に slide.yy:mm:dd.${PHOTOFORMAT} というファイル名で出力される
SLIDE_PREFIX=slide

# 静止画切り出しのタイミングを指定する
# RATE=1 なら毎秒１枚
# RATE=0.1 なら10秒ごとに１枚（毎秒0.1枚）
RATE=1
isRATEdecimal=$( echo "$RATE > 1" | bc )

# 出力画像形式を設定する
PHOTOFORMAT=jpg

moviefile=$1
pathname_tmp=${moviefile// /_}
pathname=${pathname_tmp%.*}
workdirname="${pathname}.$( date +"slide_%Y%m%d_%H%M%S" )"
mkdir $workdirname

# ffmpeg で動画から音声を抜き出す
ffmpeg -i "$1" -map 0:1 -vn -ac 2 -acodec pcm_s16le  -f wav ${workdirname}/audio.wav
ffmpeg -i "$1" -map 0:1 -vn -ac 2 -acodec libmp3lame -f wav ${workdirname}/audio.mp3

# ffmpeg で動画から静止画切り出し（ここでは常にpngで出力する）
ffmpeg -i "$1" -r ${RATE} ${workdirname}/${SLIDE_PREFIX}.%d.png

# もしも単純に動画から全シーンの静止画を抜き出したいだけなら
# 処理は ffmpeg だけで完結するので、
# 下記の exit が実行されるようにして、ここで処理を終了させればよい。
# exit


cd ${workdirname}

# 最終フレームの通し番号を取得する
finalslide_number=$( ls | wc -l )

# 重複したフレームを合成してノイズを除去する
# 合成後は重複フレームを消す
merge_and_delete_duplicated_photos() {
    local same_picture_begin=$1
    local same_picture_end=$2
    local j

    if [ "${same_picture_begin}" != "${same_picture_end}" ];then
        if [ "${SEQUENCE}" != "" ]; then
            convert -evaluate-sequence ${SEQUENCE} $(
                for j in $( seq ${same_picture_begin} ${same_picture_end} ) ; do
                echo ${SLIDE_PREFIX}.$j.png
                done
            ) zmedian.${PHOTOFORMAT}
        fi

        for j in $( seq $((same_picture_begin+1)) ${same_picture_end} ) ; do
            rm ${SLIDE_PREFIX}.$j.png
        done

        if [ -e zmedian.${PHOTOFORMAT} ]; then
            mv zmedian.${PHOTOFORMAT} ${SLIDE_PREFIX}.${same_picture_begin}.${PHOTOFORMAT}
        fi
    fi

    if [ "${PHOTOFORMAT}" != "png" ]; then
        convert ${SLIDE_PREFIX}.${same_picture_begin}.png ${SLIDE_PREFIX}.${same_picture_begin}.${PHOTOFORMAT}
        rm ${SLIDE_PREFIX}.${same_picture_begin}.png
    else
        pngquant ${SLIDE_PREFIX}.${same_picture_begin}.png
        mv ${SLIDE_PREFIX}.${same_picture_begin}-fs8.png ${SLIDE_PREFIX}.${same_picture_begin}.png
    fi 
}


detect_duplicated_photos() {
# 各フレーム間の PSNR を比較し、値が閾値を超えているかどうかを判定する処理
    local same_picture_begin=1
    local same_picture_end=1
    local i

    for i in $( seq 1 ${finalslide_number} ); do

        filename_current=${SLIDE_PREFIX}.$i.png
        filename_next=${SLIDE_PREFIX}.$((i+1)).png

        filename_start=${SLIDE_PREFIX}.${same_picture_begin}.png

        if [ -e $filename_current ] && [ -e $filename_next ]; then

            # ２つの画像を単純比較する
            diff -b $filename_start $filename_next > /dev/null

            if [ $? -eq 0 ]; then
                # 完全に一致なら PSNR=0 とする
                PSNR=0
            else
                # 不一致なら PSNR を真面目に計算する
                PSNR=$( compare -metric PSNR $filename_start $filename_next zdiff.png 2>&1 )
                rm zdiff.png

                # PSNR の小数点部分を捨てる
                PSNR=${PSNR%.*}
            fi

            echo -en "$filename_start -> $filename_next : PSNR = " $PSNR

            # PSNR が 0 またはしきい値を超えた場合は削除
            if [ $PSNR -eq 0 ] || [ $PSNR -ge $PSNR_threadshold ]; then
                echo -n " : delete"
                same_picture_end=$((i+1))
            else
                echo -n " : convert to ${PHOTOFORMAT}"
                merge_and_delete_duplicated_photos $same_picture_begin $same_picture_end

                # if [ "${PHOTOFORMAT}" != "png" ]; then
                #     echo " * need to convert to ${PHOTOFORMAT}"
                #     convert ${SLIDE_PREFIX}.$same_picture_begin.png ${SLIDE_PREFIX}.$same_picture_begin.${PHOTOFORMAT}
                # fi 

                same_picture_begin=$((i+1))
                same_picture_end=$((i+1))
            fi
            echo ""
        fi
    done
    # 最後の画像処理をここで実施
    merge_and_delete_duplicated_photos $same_picture_begin $same_picture_end
}

rename_filename_with_timestamped() {
    # ${SLIDE_PREFIX}.60.${PHOTOFORMAT} → ${SLIDE_PREFIX}.00.01.00.${PHOTOFORMAT} のようなrenameを実施する
    for file in $( ls ${SLIDE_PREFIX}.*.${PHOTOFORMAT} )
    do
        filename_tmp=${file%%.${PHOTOFORMAT}}
        number=${filename_tmp##${SLIDE_PREFIX}.}
        number=$(( number + 0 ))

        raw_seconds=$( echo "scale=2; $number/$RATE" | bc )
          hour=$( echo "scale=0; $raw_seconds/3600"            | bc )
        minute=$( echo "scale=0;($raw_seconds -$hour*3600)/60" | bc )
        second=$( echo "scale=0;($raw_seconds -$hour*3600 -$minute*60)*2/2" | bc )

        if [ $isRATEdecimal == 1 ]; then
            millisecond=$( echo "scale=0; ($raw_seconds -$hour*3600 -$minute*60 -$second)*100" | bc )
            timestamp=$(printf "%02d.%02d.%02d.%02d" ${hour%.*} ${minute%.*} ${second%.*} ${millisecond%.*} )
        else
            timestamp=$(printf "%02d.%02d.%02d" $hour $minute $second )
        fi
        mv $file ${SLIDE_PREFIX}.$timestamp.${PHOTOFORMAT}
    done

    # ファイル名のタイムスタンプを時刻順に揃える
    for file in $( ls ${SLIDE_PREFIX}.*.${PHOTOFORMAT} ) ; do touch $file ; done
}

detect_duplicated_photos
rename_filename_with_timestamped
```

# 実行例
約50分のムービーをこのスクリプトにかけてみると、10秒単位で切り出した画像枚数は289枚でした（RATE=0.1に設定）。しかしPSNR による画像マッチングにより、最終的には40枚まで切り詰めることができました。

この場合の処理時間はおよそ２～３分くらいでした。

```bash
kinoue@ubuntu1604LTS:~$ ./movie2slide.sh "some_movie.mp4"
ffmpeg version 2.8.11-0ubuntu0.16.04.1 Copyright (c) 2000-2017 the FFmpeg developers
  built with gcc 5.4.0 (Ubuntu 5.4.0-6ubuntu1~16.04.4) 20160609
  configuration: --prefix=/usr --extra-version=0ubuntu0.16.04.1 --build-suffix=-ffmpeg --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --cc=cc --cxx=g++ --enable-gpl --enable-shared --disable-stripping --disable-decoder=libopenjpeg --disable-decoder=libschroedinger --enable-avresample --enable-avisynth --enable-gnutls --enable-ladspa --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libgme --enable-libgsm --enable-libmodplug --enable-libmp3lame --enable-libopenjpeg --enable-libopus --enable-libpulse --enable-librtmp --enable-libschroedinger --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libssh --enable-libtheora --enable-libtwolame --enable-libvorbis --enable-libvpx --enable-libwavpack --enable-libwebp --enable-libx265 --enable-libxvid --enable-libzvbi --enable-openal --enable-opengl --enable-x11grab --enable-libdc1394 --enable-libiec61883 --enable-libzmq --enable-frei0r --enable-libx264 --enable-libopencv
  libavutil      54. 31.100 / 54. 31.100
  libavcodec     56. 60.100 / 56. 60.100
  libavformat    56. 40.101 / 56. 40.101
  libavdevice    56.  4.100 / 56.  4.100
  libavfilter     5. 40.101 /  5. 40.101
  libavresample   2.  1.  0 /  2.  1.  0
  libswscale      3.  1.101 /  3.  1.101
  libswresample   1.  2.101 /  1.  2.101
  libpostproc    53.  3.100 / 53.  3.100
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'some_movie.mp4':
  Metadata:
    major_brand     : mp42
    minor_version   : 0
    compatible_brands: mp42isomavc1
    creation_time   : 2017-03-16 16:50:29
  Duration: 00:47:55.78, start: 0.000000, bitrate: 144 kb/s
    Stream #0:0(eng): Video: h264 (Baseline) (avc1 / 0x31637661), yuv420p, 1920x1080, 78 kb/s, 6.35 fps, 29.85 tbr, 90k tbn, 180k tbc (default)
    Metadata:
      handler_name    : Citrix h264 stream handler
      encoder         : AVC Coding
    Stream #0:1(eng): Audio: aac (LC) (mp4a / 0x6134706D), 16000 Hz, mono, fltp, 64 kb/s (default)
    Metadata:
      handler_name    : AAC stream handler
Output #0, image2, to 'movie2slide_20170323_144644/slide%d.png':
  Metadata:
    major_brand     : mp42
    minor_version   : 0
    compatible_brands: mp42isomavc1
    encoder         : Lavf56.40.101
    Stream #0:0(eng): Video: png, rgb24, 1920x1080, q=2-31, 200 kb/s, 0.10 fps, 0.10 tbn, 0.10 tbc (default)
    Metadata:
      handler_name    : Citrix h264 stream handler
      encoder         : Lavc56.60.100 png
Stream mapping:
  Stream #0:0 -> #0:0 (h264 (native) -> png (native))
Press [q] to stop, [?] for help
frame=  291 fps= 10 q=0.0 Lsize=       0kB time=291.00 bitrate=   0.0kbits/s
video:57763kB audio:0kB global headers:0kB muxing overhead -100.000000%
slide1.png -> slide2.png : PSNR =  70 : delete
slide1.png -> slide3.png : PSNR =  58 : delete
slide1.png -> slide4.png : PSNR =  58 : delete
slide1.png -> slide5.png : PSNR =  56 : delete
slide1.png -> slide6.png : PSNR =  56 : delete
slide1.png -> slide7.png : PSNR =  50 : delete
slide1.png -> slide8.png : PSNR =  50 : delete
slide1.png -> slide9.png : PSNR =  50 : delete
slide1.png -> slide10.png : PSNR =  50 : delete
slide1.png -> slide11.png : PSNR =  50 : delete
slide1.png -> slide12.png : PSNR =  50 : delete
slide1.png -> slide13.png : PSNR =  49 : delete
slide1.png -> slide14.png : PSNR =  50 : delete
slide1.png -> slide15.png : PSNR =  10
slide15.png -> slide16.png : PSNR =  76 : delete
slide15.png -> slide17.png : PSNR =  75 : delete
slide15.png -> slide18.png : PSNR =  31 : delete
slide15.png -> slide19.png : PSNR =  31 : delete
slide15.png -> slide20.png : PSNR =  31 : delete
slide15.png -> slide21.png : PSNR =  31 : delete
slide15.png -> slide22.png : PSNR =  39 : delete
slide15.png -> slide23.png : PSNR =  39 : delete
slide15.png -> slide24.png : PSNR =  22

(中略)

slide270.png -> slide273.png : PSNR =  15
slide273.png -> slide274.png : PSNR =  48 : delete
slide273.png -> slide275.png : PSNR =  48 : delete
slide273.png -> slide276.png : PSNR =  48 : delete
slide273.png -> slide277.png : PSNR =  48 : delete
slide273.png -> slide278.png : PSNR =  48 : delete
slide273.png -> slide279.png : PSNR =  48 : delete
slide273.png -> slide280.png : PSNR =  48 : delete
slide273.png -> slide281.png : PSNR =  48 : delete
slide273.png -> slide282.png : PSNR =  48 : delete
slide273.png -> slide283.png : PSNR =  48 : delete
slide273.png -> slide284.png : PSNR =  48 : delete
slide273.png -> slide285.png : PSNR =  44 : delete
slide273.png -> slide286.png : PSNR =  44 : delete
slide273.png -> slide287.png : PSNR =  27
slide287.png -> slide288.png : PSNR =  16
slide288.png -> slide289.png : PSNR =  13
slide289.png -> slide290.png : PSNR =  30 : delete
slide289.png -> slide291.png : PSNR =  30 : delete

kinoue@ubuntu1604LTS:~$
```