はじめに
これは2021年振り返りカレンダーの10日目の記事です.
前回はTwilio Sync + Reactを用いてリアルタイム通信を実装するという内容でした.
今回はGCPに戻って,Video Intelligenceを使って不適切な動画を弾く内容について書いていこうと思います.
ユーザが動画などのコンテンツを自由に投稿できるサービスはよくありますが,ユーザ数が一定以上いると, ポルノ画像など他のユーザに害を与えるものをアップロードする人が間違いなく現れます. そのため今回は,そのような不適切コンテンツを検知し,アラートしてくれる簡易的なシステムを実装します.
超大きいサービスだと検出したいケースが複雑でこうはいかないでしょうが, 初期段階のサービスでは役に立つんではないかと思います.
仕様
Cloud Storageの特定のバケットに新たな動画がアップロードされた時,Video Inteligenceで その動画が不適切かどうか(ポルノ要素を含むかどうか)を判定し,結果をslackに通知するとします.
動画の判定処理や通知には,Cloud Functions(typescript)をもちいます.
実装
Video Intelligenceの不適切コンテンツ検出(セーフサーチ)の仕様について
ドキュメントは以下になっています.
使い方の要点をまとめると以下の感じです.
- 判定したいビデオを,GCS上のURI(gs://~~)で指定する.
- 動画のURLを直で与えることはできない.
- 判定結果としては,ビデオが数フレーム毎に分割され,それぞれに6段階で判定が行われる(VERY LIKELY 〜 VELY UNLIKELY).
- 判定の段階はこちら.Likelihood | Cloud Video Intelligence API Documentation | Google Cloud
- 判定には動画のビジュアル部分のみが利用され,音声は関係ない.
- 各フレームの判定結果をもとに自分で適切な閾値を実装し,ビデオ全体の判定結果を出す.
Video Intelligenceを用いた判定処理
次は実際に,Cloud Functionにのせる用のコードを書いていきます.
GCSの特定のバケットにオブジェクトが追加された時に処理を走らせるため, Cloud FunctionsのGCSトリガーを用います.
また,Video Intelligenceでは動画全体で1つの判定結果が返ってくるのではなく, 複数あるフレームの単位で判定結果が返ってくるので,それを1つの判定結果に落とし込む必要があります. 今回は以下のようなフローで判定を行うこととしました.
- 全フレームのうち,1つでもVERY LIKELYの箇所があるなら,不適切と判定.
- 全フレームのうち,40%以上がPOSSIBLEなら,不適切と判定.
- 上記に当てはまらないなら,適切な動画と判定.
実際の実装は以下のような感じです.
import videoIntelligence, { protos } from "@google-cloud/video-intelligence"; const pornoFrameRatioThreshold = 0.4; export const safeSearch = functions .region(REGION) .storage.bucket(functions.config().storage.bucket) .object() .onFinalize(async (object) => { // GCSの新規オブジェクトのパスを取得 const filePath = object.name; // パスのバリデーション if (!filePath) { functions.logger.error("Filepath is null."); return; } if (!validateVideoEdit(object)) { return; } // オブジェクトが入っているバケット名を取得 const bucket = admin.storage().bucket(object.bucket); const userUID = filePath.split("/")[0]; const request = { inputUri: `gs://${bucket.name}/${filePath}`, features: [ protos.google.cloud.videointelligence.v1.Feature .EXPLICIT_CONTENT_DETECTION, ], }; // Video Intelligenceでセーフサーチをする const client = new videoIntelligence.VideoIntelligenceServiceClient(); const [operation] = await client.annotateVideo(request); const [operationResult] = await operation.promise(); // 結果の取り出し const annotations = operationResult.annotationResults ? operationResult.annotationResults[0] : undefined; // 判定されたフレームの数を取得 const frameCount = annotations?.explicitAnnotation?.frames?.length; // フレーム全体を見て判定 const pornographyPossibleCount = annotations?.explicitAnnotation?.frames?.filter( (v) => v.pornographyLikelihood == protos.google.cloud.videointelligence.v1.Likelihood.POSSIBLE ).length; if (typeof pornographyPossibleCount === "undefined" || !frameCount) { functions.logger.error("Video Annotation Failed."); return; } const isPossiblyPorno = annotations?.explicitAnnotation?.frames?.some((v) => v?.pornographyLikelihood ? v?.pornographyLikelihood > protos.google.cloud.videointelligence.v1.Likelihood.POSSIBLE : false ) || pornographyPossibleCount / frameCount >= pornoFrameRatioThreshold; // 結果出力 console.log(isPossiblyPorno); });
結果をSlackへ通知
不適切な動画がサービスに投稿されたらすぐにお知らせしてほしいので, slackに通知を送ることとします.
前述したコードの最後の判定結果 isPossiblyPorno を用いた以下の感じの処理を挟みます.
export const safeSearch = functions .region(REGION) .storage.bucket(functions.config().storage.bucket) .object() .onFinalize(async (object) => { // 前述の判定処理 // 不適切判定がTrueなら通知 if (isPossiblyPorno) { // slack通知のためのトークンなどを環境変数から取得 const SLACK_TOKEN: string = functions.config().slack.oauth_token; const SLACK_INSPECTION_CHANNEL: string = functions.config().slack.inspection_channel; const slackClient = new WebClient(SLACK_TOKEN); const STORAGE_PUBLIC_HOST: string = functions.config().storage.public_host; const videoUrl: string = new URL( path.join(bucket.name, filePath), STORAGE_PUBLIC_HOST ).href; try { await slackClient.chat.postMessage({ channel: SLACK_INSPECTION_CHANNEL, blocks: [ { type: "section", text: { type: "mrkdwn", text:"<!channel>\n危険な動画が投稿された可能性があります!!!", }, }, { type: "actions", elements: [ { type: "button", text: { type: "plain_text", text: "ビデオを確認", }, url: videoUrl, }, ], }, ], }); } catch (error) { functions.logger.error(`Sending a slack message failed`); } } return; }
これで実装が完了です.
実サービスに投入しての,精度とかに関する所感
実際に,定期的に動画がアップロードされる実サービスに導入してみた所感が以下のような感じです. アップロード数は大体日に50~100ぐらい?
- 稀にアップロードされる完全にアウトな動画(陰部丸出しの自撮り,AVなど)は,ほぼLIKELY以上となっており,全部弾けていた.数千上がってきて2つぐらいあった.
- 水着や男性の上半身裸などにもLIKELYぐらいで反応するので,その辺は人手の追加チェックが必要(サービスの仕様によると思う).大体日に3, 4件ぐらいは反応していた.
- 手だけ映っているような,セーフだが肌色が多めな画像にも時々反応していた.
ということで,人目で見て明らかにアウトなものは漏れなく検出できましたが,セーフなものも数%ぐらいは検出してしまうので,人手による追加チェックは必須だなという印象でした(AIの判断結果を100%信用するというのもあんまりなさそうな気はしますが).
ちなみに処理時間は,動画の長さにもよりますが大体30秒~1分ぐらい.時々Cloud Functionsの実行時間のデフォルトlimitである1分を超えてしまっていたので,その辺は実際に導入して調整する感じになると思います.
その他細かい知見など
- Video Intelligenceは処理回数,処理時間に対してrate limitがあり,動画数個ですぐ引っかかるので,最初からあげておいた方が良い.
- 公式的には判定できるのはポルノ動画かどうかのみになっており,バイオレンス動画かどうかなどは判定できない模様.ただ,銃撃戦とかの動画をあげたら反応したので,全くというわけでもないのかもしれない(たまたまかもです).
- 今回は使わなかったが,人物検出とかロゴ認識とか色々あるので,初期サービスで人間的処理の自動化を簡単にでも導入するにはとても便利だと思いました.
最後に
GCPのVideo Intelligenceを用いて,GCSに上がった動画に自動でセーフサーチを行う内容について書きました. もしこれを自分で1からやることを考えると,モデルの学習や管理,MLサーバのホスティングなど大量の作業が必要になるので, こんな感じでML処理をラップしてくれるサービスはありえんほど便利だなと思いました. 一方,サービスの要件によってどの程度の精度が必要か,目的のタスクを解けるかはかなり違ってきそうなので, その辺の検証を行うのは大事だなと実感しました(今回だとどんぐらいの誤検知は許容するかなど).
次回はアプリの方に戻って,強制アップデートの実装について書いていこうと思います.