【Python】動画から特徴量マッチングでPDF資料を自動生成するWebアプリを作ってみた

授業動画だけ配布されて<font color="blue">PDF資料が配布されなかった経験</font>、Zoomのウェビナー等で<font color="blue">「資料が欲しい」と言いにくかった経験</font>はありませんか？

この記事では、
1. 動画から一定間隔でフレームを切り出す
2. <font color="red">**AKAZE**（特徴量マッチング手法の1つ）</font>で、類似スライドを削除する
3. 選ばれたスライド画像を結合してPDFにする 

という3ステップで問題を解決したいと思います。


# 0. デモ

Streamlitで作成したWebアプリを公開しています。

▼ WebアプリのURL
[https://share.streamlit.io/kitsuya0828/lecapy/main/app.py](https://share.streamlit.io/kitsuya0828/lecapy/main/app.py)

![main.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/cb518f61-9fcf-2c8d-0f5e-197511c88e73.jpeg)

[YouTube](https://www.youtube.com/watch?v=-df1Wrm1CQ4)でも開発→デモの流れを紹介しているのでご覧ください。

<iframe width="560" height="315" src="https://www.youtube.com/embed/-df1Wrm1CQ4" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>


# 1. 動画から一定間隔でフレームを切り出す

ユーザーが`n`秒ごとにキャプチャしたい場合、フレーム抽出間隔`step_frame`は

```math
\begin{align}
{step\_frame \ \rm{[frames]}} = \frac{frame\_rate\ \rm{[fps]}}{n\ \rm{[s]}} 
\end{align}
```
と事前に求めることができます。
`OpenCV`を使って、一定間隔で抽出したフレームを画像として保存します。

```Python
def capture_frame(video_path: str, step_frame: int, UUID: str):
    cap = cv2.VideoCapture(video_path)
    video_frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))

    if not cap.isOpened():
        return
    
    digit = len(str(video_frame_count)) # 総フレーム数の桁数
    frame_index = 0
    caps =[]

    for n in range(0, video_frame_count, step_frame):
        cap.set(cv2.CAP_PROP_POS_FRAMES, n)
        ret, frame = cap.read()
        frame_index += 1

        if ret:
            image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            im = Image.fromarray(image)
            im.save(Path(f'static/{UUID}_{str(n).zfill(digit)}.jpg').absolute())
            caps.append(n)
    
    return frame_index, digit, caps
```

`UUID` ([Universally Unique Identifier](https://ja.wikipedia.org/wiki/UUID)) は、アプリケーションを通して使われるユーザーの（一意な）識別子です。
保存した画像をユーザーごとに区別するために使いましたが、個人で開発する分には気にしなくて大丈夫です。 

# 2. AKAZEで類似スライドを削除する
<font color="red">**AKAZE**</font>は**特徴量マッチング**の１つです。
Deep Learningではなく、またテンプレートマッチングよりも<font color="red">回転や拡大・縮小に強い</font>です。
![compare.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/c874a626-232a-c5b2-f59f-6ab7627e6f37.jpeg)

今回は、（PowerPointなどの）スライドショーを用いて行われる講義動画を想定しているので、<font color="red">アニメーションの動きにも影響されにくい</font>と考えました。
**AKAZE(Accelerated KAZE)特徴量検出**は、`OpenCV`を使って簡単に実装できます。
OpenCV公式ドキュメント : [OpenCV: AKAZE local features matching](https://docs.opencv.org/3.4/db/d70/tutorial_akaze_matching.html)

以下の記事も参考にさせていただきました。
[OpenCVのAKAZEで顔写真の類似度判定をやってみた \| パソコン工房 NEXMAG](https://www.pc-koubou.jp/magazine/43855)
```Python
def deduplicate_frame(caps: list, digit: int, UUID: str, threshold: int, frame_size: tuple, scale: int):
    IMG_SIZE = tuple(map(lambda x: x//scale, frame_size))    # 任意（フレームをリサイズすると処理が高速化する）

    dump_list = []    # 削除する類似画像のパスを格納するリスト
    files = [f'static/{UUID}_{str(n).zfill(digit)}.jpg' for n in caps]
    que = deque(files[::-1])    # 抽出した画像ファイルのパスを格納したキュー

    while que:
        # AKAZE特徴量検出
        target_file = que.popleft()
        target_img = cv2.imread(target_file, cv2.IMREAD_GRAYSCALE)    # 比較元の画像
        if scale != 1:
            target_img = cv2.resize(target_img, IMG_SIZE)

        bf = cv2.BFMatcher(cv2.NORM_HAMMING)
        detector = cv2.AKAZE_create()
        (target_kp, target_des) = detector.detectAndCompute(target_img, None)

        same_slide = True    # 類似スライドかどうかの判定フラグ

        # 類似スライドである限り比較元は変更しない
        while same_slide:
            if not que:
                break

            comparing_file = que.popleft()

            try:
                comparing_img = cv2.imread(comparing_file, cv2.IMREAD_GRAYSCALE)    # 比較する画像
                if scale != 1:
                    comparing_img = cv2.resize(comparing_img, IMG_SIZE)

                (comparing_kp, comparing_des) = detector.detectAndCompute(comparing_img, None)
                matches = bf.match(target_des, comparing_des)

                dist = [m.distance for m in matches]
                ret = sum(dist) / len(dist)    # 特徴量の距離平均
            except cv2.error:
                ret = 10**6

            if ret < threshold:    # ユーザー定義の閾値よりも小さい場合
                dump_list.append(comparing_file)
            else:
                que.appendleft(comparing_file)    # 次の比較元の画像に使う
                same_slide = False
    return dump_list[::-1]
```
類似画像をネガポジ反転させた結果を表示してみると、いい感じのPDFが完成しそうです。
![bb241f6ec6f1d71b199d7524536f9231455c7c234134c5fb0c013197 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/c43cd32b-f661-ea20-ce77-b2b730d5a010.png)
※ Webアプリの結果プレビュー画面です（著作権保護のためモザイクをかけています）

# 3. 選ばれたスライド画像を結合してPDFにする 
`img2pdf`モジュールを使って、画像を簡単にPDFファイルに変換できます。
参考記事 : [img2pdfを使用しPythonで画像をPDFファイルに変換する \| Men of Letters（メン・オブ・レターズ） – 論理的思考/業務改善/プログラミング](https://laboratory.kazuuu.net/convert-images-to-pdf-files-in-python-using-img2pdf/)

抽出したすべてのフレームのリスト`all_img_list`から、類似フレームのリスト`dump_list`の画像を削除してPDF化します。
```Python
def generate_pdf(UUID: str, dump_list: list, all_img_list: list):
    with open(f'{UUID}.pdf', "wb") as f:
        pdf_file_path_list = []
        for file_path in all_img_list:
            if file_path not in dump_list:
                pdf_file_path_list.append(file_path)
        f.write(img2pdf.convert(pdf_file_path_list))
```

# 4. 全体ソースコード
StreamlitでWebアプリ化した全体ソースコードは、以下のGitHubで公開しています。
[Kitsuya0828/Lecapy](https://github.com/Kitsuya0828/Lecapy)
よろしければご覧ください。

# 5. おわりに
この記事や作成したWebアプリが少しでも皆さんのお役に立てれば幸いです。