2021年1月18日月曜日

k-means法でプレゼン動画をショット分割する

講義や講演のプレゼン動画をスライドごとにショット分割したくて作った簡単なスクリプトを記録しておく. スライド枚数はおおよそ発表分数程度を想定していて,スライド中に動画がバンバン含まれるようなものは想定外. k-means法は予め指定したクラスタ数(k個)にデータを分割できるので,動画フレームをk枚のスライドに分割するという作戦. k-means法とはたくさんのデータを似たもの通しくっつけてk個のグループに分けるアルゴリズム. 何個に分けていいか分からないときにつかうmeanshift法でもいいかも.

準備

ライブラリは画像を使うのでOpenCVとnumpyは当たり前として,k-means法についてはscikit-learnのcluster.KMeansを使用. フレームを無作為抽出してクラスタリングするためrandomも使います.

import cv2
import numpy as np
import random
from sklearn.cluster import KMeans

フレーム数と動画の時間を取得する.それを分数になおしてスライド枚数とする. また,動画のフレームサイズが大きすぎるとメモリを使いすぎるので 内容が判別できる程度に縮小したい. ここではフレームサイズ取得用に動画を1フレームだけ取り出しておく.

window_name = "video"
cap = cv2.VideoCapture('test.mp4')
n_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))	#フレーム数
fps = cap.get(cv2.CAP_PROP_FPS)				#1枚だけ取得
n_divisions = int(n_frames/fps/60)			#クラスタ数
ret, frame = cap.read()					#再生位置を初期フレームに戻す

ちなみに使用した動画はこちら.Youtubeは直接ダウンロード禁止なので,mp4ファイルを所有者から頂いた. 量子コンピュータの原理をペロッと味あわせてくれる面白い講義です.

無作為抽出したフレームをクラスタリング

全フレームをクラスタリングしようとすると, 動画の全画像をメモリ上に配置しないといけない(たぶん他に方法あると思うが)ので, ここでは無作為抽出したフレーム群を用いてクラスタに分ける. さらに画像は8分の1のサイズに縮小しておく. 無作為抽出するために全フレームの番号の入った配列frame_listをシャッフルしておく. random.seed(1)は結果を毎回同じにするため.

# フレーム番号をシャッフル
frame_list = list(range(n_frames))
random.seed(1)
random.shuffle(frame_list)

# 解像度を下げる
frame2 = cv2.pyrDown(frame)
frame4 = cv2.pyrDown(frame2)
frame8 = cv2.pyrDown(frame4)

# 1次元ベクトル化した画像を積み上げるための変数Xを準備
shape = (0, np.ravel(frame8).shape[0])
X = np.empty(shape)

フレーム群の枚数は分けたいクラスタの数の20倍にしておく. これで十分かどうかは動画よる. for文で1フレームずつ動画から画像を無作為抽出して, 画面に表示しつつ,1次元ベクトル化して配列Xにスタックしていく. 最後のcv2.destroyWindow関数はJupyter Notebook用. Jupyter Notebookでは各セルの実行が終わってもwindowが残り続けるので,それを強制的に閉じるため.

# フレーム番号のリスト先頭からn_divisions枚だけ使用
for i in range(n_divisions*20):
    print("set %06d-th frame" % frame_list[i])
    cap.set(cv2.CAP_PROP_POS_FRAMES, frame_list[i])
    ret, frame = cap.read()
    if ret == False:
        print("could not get frame")
        continue
    frame2 = cv2.pyrDown(frame)
    frame4 = cv2.pyrDown(frame2)
    frame8 = cv2.pyrDown(frame4)
    cv2.imshow(window_name, frame8)
    key = cv2.waitKey(100)
    if key == 27:
        break
        
    # frame8をXの最下行に1次元配列として追加
    X = np.insert(X, X.shape[0], np.ravel(frame8), axis=0) 
cv2.destroyWindow(window_name)

k-means法を実行してn_divisions個のクラスにクラスタリング.

# k-meansクラスタリングで学習
kmeans = KMeans(n_clusters=n_divisions, random_state=0).fit(X)
centers = kmeans.cluster_centers_

これがクラスタ中心になっている画像.よく見ると複数の画像がアルファブレンドされたようになっている. 各画像の解像度が1/8になっているせいもある. 似た画像を平均してるのだからそうなって当然だけど,あまりボケボケになっていないということは,ちゃんと似た画像だけが1つのクラスタに分類されている証拠かな.

# クラスタ中心となる画像
rows = centers.shape[0]//4
for i in range(centers.shape[0]):
    ax = plt.subplot(4, rows+1, i+1)
    ax.axis('off')
    img = centers[i].reshape(frame8.shape).astype(np.uint8)
    img_bgr = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    plt.imshow(img_bgr)

全フレームをクラスタに分類

n_frames枚の画像フレーム全部をn_divisions個のクラスタのいずれかに分類する. ここでも全フレーム全部を一気にやってもいいのだけど,メモリを心配してfor文で一枚ずつ分類する. 分類されたクラスタのラベル番号を動画フレームの左上に表示している. scoreを用いて最もクラスタ中心に近い画像のインデックスを保存している.

modoids = np.ones(n_divisions).astype(np.int32)*-1
mindists = np.ones(n_divisions)*sys.maxsize
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
for i in range(n_frames):
    ret, frame = cap.read()
    if ret == False:
        print("could not get frame")
        continue
    frame2 = cv2.pyrDown(frame)
    frame4 = cv2.pyrDown(frame2)
    frame8 = cv2.pyrDown(frame4)

    # 一次元化して最も近いクラスタラベルを求める
    shape = (0, np.ravel(frame8).shape[0])
    Y = np.empty(shape)
    Y = np.insert(Y, Y.shape[0], np.ravel(frame8), axis=0) 
    label = kmeans.predict(Y)
    
    # -score(重心からの距離の自乗和)の最小値とそのときのラベル
    score = kmeans.score(Y)
    if(-score < mindists[label[0]]):
        mindists[label[0]] = -score
        modoids[label[0]] = i
    
    # フレーム番号,ラベル,スコアを表示
    text = "%06d-th frame, label: %03d, score: %f" % (i, label[0],-score)
    cv2.putText(frame, text, (10, 20), cv2.FONT_HERSHEY_COMPLEX_SMALL, 1, (255, 255, 255), 3, cv2.LINE_AA)
    cv2.putText(frame, text, (10, 20), cv2.FONT_HERSHEY_COMPLEX_SMALL, 1, (0, 0, 0), 1, cv2.LINE_AA)
    cv2.imshow(window_name, frame)
    key = cv2.waitKey(1)
    if key == 27:
        break
cv2.destroyWindow(window_name)

クラスタ中心に最も近い画像を表示

さっきの画像とほとんど同じに見えるけど,よく見るとこちらはくっきりしている.

# modoidsを表示
rows = centers.shape[0]//4
for label in range(modoids.shape[0]):
    ax = plt.subplot(4, rows+1, label+1)
    ax.axis('off')
    cap.set(cv2.CAP_PROP_POS_FRAMES, modoids[label])
    ret, frame = cap.read()
    if ret == False:
        print("could not get frame")
        continue
    frame2 = cv2.pyrDown(frame)
    frame4 = cv2.pyrDown(frame2)
    frame8 = cv2.pyrDown(frame4)
    img_bgr = cv2.cvtColor(frame4, cv2.COLOR_BGR2RGB)
    plt.imshow(img_bgr)

参考