はじめに
DELISH KITCHENでデータサイエンティストをやっている山西です。
今回はレシピ動画のサムネイル画像の自動抽出の取り組みについて紹介いたします。
OpenCVを用いた画像処理
画像とテキスト情報のペアを扱う大規模モデル
等を用いつつそれを試みた事例になります。
※記事後半で具体実装を扱っている部分では、周辺知識がある前提で説明を進めていることをご了承ください。
every Tech Blog Advent Calendar 2024(夏) 9日目の記事になります。
出来たもののイメージ
どんなものが出来たかを先に紹介します。
一言で表すと、レシピ動画の中から「調理手順を表すのに良い感じのサムネイル画像」をAI的振る舞いで自動で抽出してくれるシステムになります。
これをワンパンカルボナーラというレシピに適用した例を以下に載せています。
このように、5つの”手順”別にサムネイル画像の候補が抽出され、最終的に1枚が選定されます。
取り組みの背景
なぜこれを作ろうと思ったかを説明していきます。
レシピ手順説明文と共にサムネイル画像を追加したい
DELISH KITCHENでは約5万本のレシピ(2024/6月現在)を提供しており、全てのレシピに調理工程を撮影、解説した動画が付いています。
そして、調理手順ごとに区切られた説明文をレシピページ上で読むことが出来ます。
しかし、従来のスマホブラウザ版のDELISH KITCHENでは、文字情報だけでここの手順を読み進める作りになっていました。
そんな中、「各手順に動画から抽出したサムネイル画像を加えることで、工程がよりイメージしやすくなる」という仮説のもとで、サムネイルを自動付与する施策が企画されました。
結果、DELISH KITCHENの全レシピにサムネイル画像が機械的に施されることとなりました。
AWS Elemental MediaConvert
を用いたこの取り組みは以下の記事に詳しいです。
tech.every.tv
困りごと
自動処理により全レシピにサムネイルを付与出来たのは良いものの、これらはあくまで機械的なルールで付与されているため、「必ずしも調理手順の説明文に合った画像とは限らない」問題が発生し、その品質には課題が残ることとなりました。
そのため現在(2024/6月時点)、「イマイチなサムネイル」を人力で毎日少しずつ入稿して差し替える手間がかかっています。
しかし、約5万本もあるレシピを対象にこれらを行うのも骨が折れる作業です。
PoCの実施
こうした取り組みを横目で見ている中、「画像処理や大規模モデル等の技術スタックを使えば、”AI”として良い感じのサムネイルを抽出可能なのでは」という閃きが生まれました。
このアイデアをエブリー社内エンジニアで定期開催している挑戦WEEKの企画として提案したところ好評だったので、1週間PoCとして取り組んでみました。
システム構成
ここから成果物の具体の説明になります。
このAIシステムは、①画像処理パート、②AI処理パートの2段階で構成されます。
①事前に良さそうなサムネイル候補画像を数枚ピックアップしておき、②その中から最も良いものをAIに選ばせるという思想になります。
① OpenCV画像処理パート
- 各手順の「サムネイル画像候補」を画像処理にて抽出する(最大10件ほど)
- 全動画フレームに対してOpenCVによる画像処理を行い、それを実現する
② AI処理パート
- ①で抽出された「サムネイル画像候補」の中から、その手順に相応しい1枚を選び出す
- 手順説明文のテキスト情報とサムネイル候補の画像情報を共に解釈し、「サムネイルとしての相応しさ」を判定できるようなAIモデル(画像とテキスト情報を共に処理できるマルチモーダルモデル)を採用する
これから、実装詳細について説明します。
実装詳細
①画像処理パート: サムネイル候補画像の抽出
「良いサムネイル候補」を満たす要件の仮説をまず立て、それを実装に起こしつつ検証していきました。 これを、処理の流れと共に追っていきます。
仮説1. 動画の前後のフレームで「動き」が大きいほど、候補として重要な場面である
サムネイルとして見栄えのするシーンは大抵、「ダイナミックな動きがあったり、画角の中に多くの要素があったりする」ものではないかと考えました。
具体的には以下のような例が挙げられます。
- フライパンの上でかき混ぜているようなシーン
- フライパンの上に肉や野菜等の具材がたくさん盛り付けられているシーン
そして、「動画の前後のフレームで画像データ間の”違い”が大きいシーンを見つけ出す」ことで、上記アイデアをOpenCVを用いた動画像処理に落とし込めるのではないかと仮説立てました。
そこで、今回はAKAZEアルゴリズム
によって各フレーム画像の特徴点を抽出し、動画内の前後のフレームの特徴点の総当たりマッチング
によって「距離」を算出するという実装に落とし込みました。
平たく言えば、「特徴点という”違いの判断材料"を各フレーム画像ごとに作り、前後のフレーム間でそれらの “違い度合い”を数値(距離)として表現する」アプローチです。
詳細な説明は本記事の対象外とします。
代わりといっては何ですが実装の雰囲気や参考記事を以下に載せます。
コード例
# 動画ファイルを読み込む cap = cv2.VideoCapture(target_recipe_video_path) # フレームレートと総フレーム数を取得 fps = cap.get(cv2.CAP_PROP_FPS) total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # 開始フレームと終了フレームを計算 start_frame = int((step_start_msec / 1000) * fps) end_frame = int((step_end_msec / 1000) * fps) # 動画を読み込み、指定された範囲のフレームを書き込む cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) print('start_frame: ', start_frame) print('end_frame: ', end_frame) previous_frame = None previous_target_des = None previous_mean_distance = None for frame_num in range(start_frame, end_frame): ret, frame = cap.read() # Crop the frame # frame = crop_frame(frame) # 明暗変化による動体誤判定を防ぐために、グレースケール化 frame_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # BFMatcherオブジェクトの生成 bf = cv2.BFMatcher(cv2.NORM_HAMMING) # AKAZEを適用、特徴点を検出 detector = cv2.AKAZE_create() (_, target_des) = detector.detectAndCompute(frame, None) if previous_target_des is not None: try: # BFMatcherで総当たりマッチングを行う matches = bf.match(target_des, previous_target_des) #特徴量の距離を出し、平均を取る distance = [m.distance for m in matches] mean_distance = sum(distance) / len(distance) print('frame:', frame_num, 'ret', mean_distance) frame_and_mean_distances[step_num][frame_num] = mean_distance except: # エラーが出た場合は、直前の距離指標で補完する print('frame:', frame_num, 'error occured.', 'distance: ',[m.distance for m in matches]) frame_and_mean_distances[step_num][frame_num] = previous_mean_distance previous_mean_distance = mean_distance previous_frame = frame previous_target_des = target_des
参考記事
こうして、「ある時点のフレームが、1つ前のフレームに対してどの程度”違い”があるか」が距離値として算出されることになります。
その結果を時系列でグラフ化したものが以下の図です。
動画の進行の中で、どのあたりでシーンの移り変わりがあったかを時系列的なデータとして表現出来ました。
ここの値が大きいシーンは、「ダイナミックな調理の動きや、多く食材があるシーンかもしれないフレーム」つまり、良いサムネイルの候補になりそうだと見立てることが出来ます。
仮説2: それぞれの候補画像が、調理手順内の多様なシーンを切り取ったものになっている
「前のフレームとの"違い"」とは別に考慮すべき要因として「シーンの多様性」があります。
各調理手順の中から良い感じの候補画像を抽出するには、「手順内全体を俯瞰してみたときに、なるべくさまざまな調理シーンが切り取られている」のが良いという考えです。
※ 例えば、同じ調理手順内といっても、まな板の上で別々の野菜を順に切ったり、調味料を加えたり混ぜたりするようなシーンが連なっていることが多々あります。こういう"多様性"をなるべく網羅したいという話です。
そこで、信号処理の視点を応用してみました。
下図6のように、移動平均で慣らしてピークとなった部分をサムネイルとして抽出すれば、「動画全体の移り変わり」の視点も加味しつつ「前後の”違い”が大きいシーン」を選べるのではないかと考え、実装に落とし込みました。
scipyにfind_peak
という便利なライブラリがあったので、図6の雰囲気で使ってみました。
ここで特定されたピーク地点付近に相当する画像が、各手順のサムネイル候補になります。
(上図の場合は、手順1で6枚, 手順2で7枚, ...といった具合に抽出されていきます。)
仮説3: 候補画像は、なるべく鮮明で、ブレていないものである
フレームをサムネイル候補として選ぶ以上、ブレているシーンは見栄えが悪いのでなるべく避けたいです。
そこで、画像のエッジ検出に用いられるラプラシアンフィルタを用いて、なるべく鮮明な輪郭を持つフレームを優先的に採用するルールを処理に加えました。
コード例
# 「ピーク検出されたフレームの前後のフレーム」の番号をまとめてframe_candidate_num_listに格納 # これらに該当するフレームに順繰りにフィルタを適用し、結果得られる値が最大のものを「ブレてない」画像として採用 for frame_candidate_num in frame_candidate_num_list: # フレームを取得 frame_candidate = tmp_frame_dict[frame_candidate_num] # グレースケール化 frame_candidate = cv2.cvtColor(frame_candidate, cv2.COLOR_BGR2GRAY) # ラプラシアンフィルタを適用 v = cv2.Laplacian(frame_candidate, cv2.CV_64F, ksize=7).var() # 分散値をリストに追加 laplacian_var_dict[frame_candidate_num] = v # vが最大となるフレームを抽出 max_frame_num = max(laplacian_var_dict, key=laplacian_var_dict.get) picked_frames[max_frame_num] = tmp_frame_dict[max_frame_num]
参考記事 piccalog.net
サムネイル候補の抽出結果
こうして組み上げた仕組みをいくつかのレシピに適用した例を紹介します。
※実際の動画やページが↓で閲覧出来ます↓
サクッとほくほく♪ かぼちゃの天ぷらのレシピ動画・作り方 | DELISH KITCHEN
こんがり焼くだけ! 豆腐とキムチのチーズ焼きのレシピ動画・作り方 | DELISH KITCHEN
なんとなくの所感ですが、「多様なシーン、かつ、意味のありそうな候補を抽出できている」気がします。
※ ぶれていたり不鮮明だったりする画像が完全に無いわけでは無いですが、ラプラシアンフィルタを仕込まない場合に比べるとその発生頻度や質は改善されている所感でした。
②AI処理パート: 「サムネイル画像候補」の中から一番良い1枚を選び出す
ここから、手順の説明文に最も見合う画像をサムネイル候補の中から1枚選定するパートです。
今回の用途だと、入力された画像とテキスト情報の関係をマルチモーダルに処理、判断できる大規模モデルが相性が良いのではと考えました。
そこで、日本語特化版CLOOBモデルの利用に至りました。
これは、画像×テキスト情報を判断可能な大規模モデルCLIPの改良版として、rinna社によって提供されているモデルとなります。
CLOOBの事前学習済みモデルは、既に画像と日本語テキスト同士の兼ね合いを判断する能力を内部表現として獲得していると予想されます。
この能力を、レシピデータに用いてみてどうなるかを試してみました。
提供元 huggingface.co
画像特徴量とテキスト特徴量のコサイン類似度の計算
今回、サムネイル候補と手順説明文の当てはまり度合いはコサイン類似度
として表現することとなります。
CLOOBモデルの画像Encoder(ViT-Bベース)、テキストEncoder(BERTベース)それぞれから得た特徴ベクトル間のコサイン類似度を計算することでこれを実現します。
以下図9がその図解です。
「複数のサムネイル候補画像の中から、"にんにくは粗みじん切りにする。"というテキストに対して、最もコサイン類似度の高いものを選ぶイメージです。
コード例
model, preprocess = ja_clip.load("rinna/japanese-cloob-vit-b-16", device=device) tokenizer = ja_clip.load_tokenizer() # 中略 # 画像とテキストそれぞれの特徴ベクトルを各種Encoderから抽出し、コサイン類似度を計算する # content: サムネイル候補画像のバイナリ, description: 手順説明文の文字列 def calc_cosine_similarity(content, description): with torch.no_grad(): # サムネイル候補画像の読み込み nparr = np.frombuffer(content, np.uint8) img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) img = Image.fromarray(img) img = preprocess(img).unsqueeze(0).to(device) # cloobモデルにサムネイル候補を渡し、画像特徴量を得る image_features = model.get_image_features(img) # cloobモデルに手順説明文を渡し、テキスト特徴量を得る description_encodings = ja_clip.tokenize( texts=description, max_seq_len=150, device=device, tokenizer=tokenizer, ) description_features = model.get_text_features(**description_encodings) # 画像特徴量とテキスト特徴量間のコサイン類似度を計算する probs = torch.cosine_similarity(image_features, description_features) return probs.tolist()[0] # scalar
参考記事 cedro3.com
サムネイル選定結果
この仕組みを用いて、具体的にどんなサムネイルが採用されたのか、これまたレシピ例で見てみます。
図8で紹介したサムネイル候補の中から、最終的に選定された画像を赤枠で囲っています↓
これまた定性的な評価になりますが、実態にそぐうサムネイルが選定されている印象を受けます。
(全てでは無いですが)、「混ぜる」「揚げる」「載せる」などの動きを表すシーンを汲み取ってくれているような気がします。
やってみた所感
今回は思いつきドリブンで、やりたいことてんこ盛りで色々試しましたが、想像以上に"それっぽい"ものが出来て手応えを得ました。以下、所感をまとめています。
OpenCV等々を組み合わせた比較的シンプルな(Not機械学習の)アルゴリズムだけでも、「多様なシーンを切り取る機構」が作れて手応えを得ました。
レシピの情報を何も与えていない事前学習済みモデルを用いただけでも、想像以上に「レシピ手順説明文の文脈」をCLOOBが読み取ってくれたことに感銘しました。大規模モデルの可能性を改めて実感することになりました。
一方、実運用を見越すとなるとコスト面での課題はあるなと感じました。
- 今構築している環境で平均約1分のレシピ動画を捌くとなると、計10分弱(①画像処理パートで4分、②CLIPパートで6分ほど)費やすこととなります。
- これを如何にして、数万本もあるレシピ処理に計算/コストの観点で最適化し、スケールさせていくかが課題となります。
終わりに
今までレシピ動画メディアでありながら、あまりデータサイエンスの文脈で動画像データを活用できていなかったので、こういう取り組みが出来て新鮮でした。
まだまだデータに眠る価値はあるなと思いました。
社内でも割と好評だったので、今は本取り組みを実用化出来ないか整理しています。PoCから実運用への昇華を目指したいところです。
この記事が何かの参考になれば幸いです。