静止画分類器と動画分類器によるフェイク動画判定の比較(ViT / SigLIP / VideoMAE を同一動画に同時適用する探究用ガイド)

概要

1枚の静止画だけを見る2つのフェイク画像分類器(ViTベースとSigLIPベース)と、16フレームの時間変化をまとめて見る動画分類器(VideoMAEベース)を、同一動画の同一顔領域に同時に適用し、動画1本ごとの最終判定を突き合わせる実験用プログラムである。あわせてオプティカルフロー、顔ランドマークの変位、輝度の左右差・上下差という低次画像特徴量を計測し、各分類器の出力確率との相関を確認する。判定の一致・不一致がどこから生じるかを、数値で検証することを目的とする。

目次

1. 目的と前提

入力が動画であっても、多くの公開フェイク検出モデルはフレーム単位の静止画分類器であり、時間方向の情報を使っていない。本教材はこの差を体験的に理解させるため、静止画分類器2種(ViT系・SigLIP系)と、時空間Transformerである動画分類器1種(VideoMAE系)を同時に走らせ、同じ顔クロップ列に対する判定を比較する。静止画分類器はサンプリングされたフレームごとに偽側クラスの確率を出力し、動画分類器は連続16フレームのクリップごとに偽側クラスの確率を出力する。いずれも動画全体で平均を取り、しきい値と比較して「この動画は偽か実写か」という1本の結論を出す。窓単位・フレーム単位の値は、その結論に至る途中経過として記録・観察する。 前提として、3モデルとも顔領域を224×224に切り出した画像列のみを入力とし、音声・メタデータ・圧縮痕跡は一切使わない。低次特徴量は説明変数の候補として計測するだけで、分類器には入力しない。したがって相関係数は「分類器の判断が単純な動き量や輝度の偏りで説明できるか」を測る指標であり、因果関係を示すものではない。またVideoMAEの微調整データは公開されていないため、その精度は本教材では未知量として扱う。

2. 手法候補の一覧

本プログラムが実装している手法(No.1〜No.5)と、比較対象として押さえておくべき2024年以降の代表的手法(No.6〜No.11)を示す。旧世代の手法は後継研究に置き換えているため、ここでは扱わない。

No.名称出典 / 入手先概要実装
1 ViT系フェイク画像分類器
prithivMLmods/Deep-Fake-Detector-v2-Model
アーキテクチャ: Dosovitskiy et al., ICLR 2021(ViT)
基盤重み: google/vit-base-patch16-224-in21k(ImageNet-21k 事前学習)
https://huggingface.co/prithivMLmods/Deep-Fake-Detector-v2-Model
静止画1枚を2クラスに分類。モデルカード記載の評価は56,001枚で accuracy 0.9212。微調整データの詳細は非公開。 あり
2 SigLIP系フェイク画像分類器
prithivMLmods/deepfake-detector-model-v1
アーキテクチャ: Zhai et al., ICCV 2023(SigLIP)
微調整データ: prithivMLmods/OpenDeepfake-Preview
https://huggingface.co/prithivMLmods/deepfake-detector-model-v1
視覚言語事前学習モデルの画像側エンコーダを流用した静止画2クラス分類器。モデルカード記載の評価は19,999枚で accuracy 0.9444。 あり
3 VideoMAE系フェイク動画分類器
Ammar2k/videomae-base-finetuned-deepfake-subset
アーキテクチャ: Tong et al., NeurIPS 2022(VideoMAE)
基盤重み: MCG-NJU/videomae-base(Kinetics-400 で自己教師あり事前学習)
https://huggingface.co/Ammar2k/videomae-base-finetuned-deepfake-subset
連続16フレームを時空間パッチ(tubelet)に分割して処理する動画分類器。時間方向の情報を直接扱う。モデルカードが存在せず、微調整データ・精度は非公開。 あり
4 MediaPipe Face Landmarker Lugaresi et al., arXiv:1906.08172, 2019 / Kartynnik et al., CVPRW 2019
https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task
単眼RGB画像から顔ランドマークを推定。本プログラムでは顔領域の切り出しと、ランドマーク変位の計測に用いる。判定そのものは行わない。 あり
(前処理)
5 Farnebäck 法による密なオプティカルフロー Farnebäck, SCIA 2003(学習を伴わない古典的アルゴリズム、OpenCV 実装) 連続する顔クロップ間の画素移動量を推定。本プログラムでは説明変数の候補として平均移動量のみを利用する。 あり
(補助指標)
6 NPR(Neighboring Pixel Relationships) Tan et al., CVPR 2024
https://github.com/chuangchuangtan/NPR-DeepfakeDetection
生成器のアップサンプリング処理が残す近傍画素間の関係の歪みを特徴量化し、未知の生成器へ汎化させる手法。 なし
7 AIDE(AI-generated Image DEtector) Yan et al., ICLR 2025
https://github.com/shilinyan99/AIDE
低次のパッチ統計と高次の意味特徴を統合し、生成画像検出のベンチマークを健全化する立場から提案された検出器。 なし
8 Effort(直交部分空間分解) Yan et al., ICML 2025 / arXiv:2411.15633 基盤モデルの重みを特異値分解し、意味を保持する部分空間と偽造痕跡を学習する部分空間を直交させることで汎化性能を高める手法。 なし
9 Community Forensics Park & Owens, CVPR 2025 / arXiv:2411.04125 4,803種の生成器から集めた270万枚規模のデータで検出器を訓練し、未知生成器への汎化を狙う「データ側」からの解法。 なし
10 UNITE(Universal Network for Identifying Tampered and synthEtic videos) CVPR 2025
https://cvpr.thecvf.com/virtual/2025/poster/33909
顔だけでなく背景や全画面生成動画も対象とする汎用の合成動画検出器。顔中心の検出器の限界に対する直接の後継。 なし
11 M2F2-Det(多モーダル解釈可能検出器) CVPR 2025
https://cvpr.thecvf.com/virtual/2025/poster/32709
大規模言語モデルと結合し、判定に加えて自然言語による根拠説明を出力する検出器。6つのデータセットで高い性能を報告。 なし

参考ベンチマーク: Deepfake-Eval-2024(2024年に実際に流通したディープフェイクを収集した実環境ベンチマーク。動画44時間・音声56.5時間・画像1,975枚)。既存検出器の実環境性能が論文中の値より大きく低下することを示している。https://github.com/nuriachandra/deepfake-eval-2024

3. 本コードが実装している手法と、候補との比較・限界

3.1 実装している3つの判定モデル

役割モデルID入力config.json の id2label本コードが偽側とする確率
静止画分類器AprithivMLmods/Deep-Fake-Detector-v2-Model224×224 の顔クロップ1枚0 = Realism / 1 = DeepfakeDeepfake(index 1)の確率
静止画分類器BprithivMLmods/deepfake-detector-model-v1224×224 の顔クロップ1枚0 = Fake / 1 = RealFake(index 0)の確率
動画分類器Ammar2k/videomae-base-finetuned-deepfake-subset224×224 の顔クロップ16枚0 = fake / 1 = realfake(index 0)の確率
クラス名の表記について(重要)。 v2(ViT)のクラス名は Realism / Deepfake、v1(SigLIP)は Fake / Real、VideoMAE は fake / real であり、3モデルで名称も並び順も異なる。特に v2 は index 1 が偽側、v1 と VideoMAE は index 0 が偽側である。コードは「ラベル名に fake という文字列を含む方を偽側とする」規則で自動的に対応付け、起動時にログへ実際の id2label を表示する。

3.2 候補手法との比較

No.1・No.2 は静止画検出の系譜に属し、フレーム間の不整合(まばたきの不自然さ、輪郭のちらつき、フレーム間の色ずれ)を原理的に扱えない。No.3 の VideoMAE は時空間の自己注意により時間方向の情報を扱えるが、微調整データが非公開であるため、どのような偽造手法に対応できるかが不明である。 一方 No.6〜No.9 は「未知の生成器への汎化」を主題としており、本教材の3モデルよりも新しい問題設定に立っている。No.10 の UNITE は顔領域に限定しない点で、顔クロップを前提とする本プログラムの構造的限界をそのまま指摘する立場にある。No.11 は判定根拠の言語化を扱っており、本教材が相関係数という間接的な方法で行っている「根拠の推定」を、モデル側から直接出力させる方向の研究である。

3.3 本プログラムで確認できること/できないこと

確認できることこのコードでは確認できないこと
・同じ顔クロップ列に対して3モデルが出す動画レベル判定の一致/不一致
・静止画分類器の確率がフレームごとにどれだけ揺れるか
・動画分類器の確率が窓ごとにどれだけ揺れるか
・偽側確率と低次特徴量(フロー・変位・輝度差)との線形相関の強さ
・パラメータ(サンプリング間隔、余白率、しきい値など)が結論に与える影響
・各モデルの真の検出精度(正解ラベル付きの十分な数の動画がないため)
・VideoMAE がどの偽造手法で学習されたか(非公開)
・判定根拠の内部的な説明(本コードは注意重みや顕著性マップを出力しない)
・音声・圧縮痕跡・メタデータに基づく判定
・複数人物が映る動画での判定(顔は1人分のみ処理する)
相関係数は線形関係の強さのみを表す。r が 0 に近くても「関係がない」とは断定できず(非線形の関係は捉えられない)、r が大きくても因果関係を意味しない。標本数が少ないとき(数十未満)は値が偶然に大きく振れるため、必ず標本数と併せて記録すること。

4. プログラムの概要と実行手順

Python 3.12 のインストール

Pythonのインストールを行い、Pythonのプログラムを実行する環境を整える。扱う環境は、Windows搭載パソコンである。金子研究室では、Python 3.12.10を推奨する。

[Windows での Python 3.12 のインストール手順を見るには、ここをクリック]

Windows での Python 3.12 のインストール

以下のいずれかの方法でPython 3.12をインストールする。Pythonがインストール済みの場合、この手順は不要である。

方法 1:winget によるインストール

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動するとPATHが反映される。

REM Python 3.12 をシステム領域にインストール
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""

REM Python と Scripts を PATH 先頭に追加
powershell -NoProfile -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; $c=[Environment]::GetEnvironmentVariable('Path','Machine'); if((Test-Path $p) -and (';'+$c+';' -notlike \"*;$p;*\") -and (';'+$c+';' -notlike \"*;$s;*\")){[Environment]::SetEnvironmentVariable('Path',\"$p;$s;$c\",'Machine')}"

方法 2:インストーラーによるインストール

  1. Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。

インストールの確認

コマンドプロンプトで以下を実行する。

python --version

バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

Build Tools・CUDA Toolkit・PyTorch のインストール

本章では、C++ ビルドツール、NVIDIA CUDA Toolkit、PyTorch のインストールを行い、GPU を活用した機械学習プログラムを実行する環境を整える。扱う環境は、Windows 搭載パソコンである。

[Build Tools・CUDA Toolkit・PyTorch のインストール手順を見るには、ここをクリック]

Windows での Build Tools for Visual Studio 2026 のインストール

Build Tools for Visual Studio 2026 は、C++ ソースコードを Windows 用バイナリにコンパイルするための開発ツール群である。unsloth 等の一部 Python パッケージは、インストール時に C++ コードのビルドを必要とするため、これらのツールが必須となる。

以下のコマンドは、Build Tools が未インストールの場合は winget で新規インストールし、インストール済みの場合は setup.exe modify でコンポーネントを追加する(バージョンは変更しない)。

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

REM VC++ ランタイム
winget install --scope machine --id Microsoft.VCRedist.2015+.x64 -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "/quiet /norestart"

REM ============================================================
REM Visual Studio Build Tools + Desktop development with C++
REM (VCTools、MSBuildTools、CMake連携、Clang、Windows 11 SDK)
REM ============================================================
REM 進行中のインストーラーを停止(ロック競合回避)
taskkill /F /IM vs_setup.exe /T >nul 2>&1
taskkill /F /IM vs_installer.exe /T >nul 2>&1
taskkill /F /IM vs_installerservice.exe /T >nul 2>&1

REM 未インストール時: winget で新規インストール
REM インストール済み時: setup.exe modify でコンポーネント追加(バージョンは変更しない)
winget list --id Microsoft.VisualStudio.BuildTools 2>nul | findstr /i "BuildTools" >nul 2>&1
if %ERRORLEVEL% EQU 0 (
    for /f "usebackq delims=" %P in (`"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products Microsoft.VisualStudio.Product.BuildTools -property installationPath`) do start /wait "" "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "%P" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --includeRecommended --quiet --norestart --nocache
) else (
    winget install --scope machine --id Microsoft.VisualStudio.BuildTools -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100"
)

REM 破損時の修復(任意、動作がおかしくなった場合)
REM "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" repair --installPath "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools" --quiet --norestart

REM 導入確認(インストールパスが表示されれば正常)
"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products * -requires Microsoft.VisualStudio.Workload.VCTools -property installationPath

上記のコマンドでは、Build Tools 本体と Visual C++ 再頒布可能パッケージをインストールし、続いて以下のコンポーネントを追加している。

追加のコンポーネントが必要になった場合は Visual Studio Installer で個別にインストールできる。

Windows での NVIDIA CUDA Toolkit のインストール

NVIDIA CUDA Toolkit は、NVIDIA GPU 上で計算を行うためのコンパイラ・ライブラリ群である。PyTorch や vLLM 等が GPU を利用するために必要となる。GPU を使用しない場合、この手順は不要である。

前提条件:NVIDIA GPU、NVIDIA ドライバ、Build Tools for Visual Studio もしくは Visual Studio が必要である。

インストール中の注意:他のウインドウは閉じておくこと。

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

REM NVIDIA CUDA Toolkit 12.8 をシステム領域にインストール
winget install --scope machine --id Nvidia.CUDA --version 12.8 -e --silent --disable-interactivity --force --uninstall-previous --accept-source-agreements --accept-package-agreements --override "-s -n"

REM 環境変数TEMP, TMPの設定(一時ファイルの保存先を短いパスに変更)
mkdir C:\TEMP
setx TEMP "C:\TEMP" /M
setx TMP "C:\TEMP" /M

環境変数 TEMP および TMP を C:\TEMP に変更しているのは、後続のインストール処理で長いパス名や空白を含むパス名がエラーの原因となる場合があるためである。

Windows での PyTorch のインストール

https://pytorch.org のインストールガイドに従い、自環境の CUDA バージョンに対応したコマンドを取得して実行する。CUDA バージョンは以下で確認できる。

nvcc --version

Python 3.12、CUDA 12.6 以上の場合は、管理者権限コマンドプロンプトを起動し、以下を実行する。cu128 は CUDA 12.8 用のタグである。CUDA バージョンが異なる場合は、上記公式サイトで該当するタグを確認し、URL 末尾の cu128 を置き換えること。

pip install --no-user -U numpy torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128

以降の章では、必要に応じて題材に応じた必要なソフトウェアを追加する。

Python の開発環境 Visual Studio Code のインストールと Python 用の設定

Python の開発環境Visual Studio Code(プログラムを編集するソフトウェア。以下、VS Code)を整える。

[Windows での Visual Studio Code のインストールと Python 用の設定手順を見るには、ここをクリック]

Windows での Visual Studio Code のインストールと Python 用の設定手順

1. VS Code と拡張機能のインストール

以下のコマンドにより,既存の VS Code を削除し,全ユーザー共有の設定で再インストールしたうえで,拡張機能(VS Code に機能を追加するソフトウェア)をまとめて導入する.

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして,コマンド全体をコマンドプロンプトにコピー&ペーストする。

インストールコマンド


REM ============================================================
REM Microsoft Visual Studio Code
REM ============================================================
winget uninstall -e --id Microsoft.VisualStudioCode --silent --disable-interactivity --accept-source-agreements
rmdir /s /q C:\ProgramData\vscode-extensions 2>nul
rmdir /s /q "%APPDATA%\Code" 2>nul
rmdir /s /q "%USERPROFILE%\.vscode" 2>nul
rmdir /s /q "%LOCALAPPDATA%\Microsoft\vscode-update" 2>nul

REM VS Code をシステム領域に新規インストール
winget install --scope machine --id Microsoft.VisualStudioCode -e --silent --accept-source-agreements --accept-package-agreements

REM 全ユーザー共有の拡張機能フォルダ
mkdir C:\ProgramData\vscode-extensions 2>nul
icacls "C:\ProgramData\vscode-extensions" /grant "Everyone:(OI)(CI)M" /T

REM スタートメニューのショートカットを --extensions-dir 付きで再作成
rmdir /s /q "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code" 2>nul
del "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk" 2>nul
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); $lnk.TargetPath='C:\Program Files\Microsoft VS Code\Code.exe'; $lnk.Arguments='--extensions-dir \"C:\ProgramData\vscode-extensions\"'; $lnk.Save()"
REM ショートカットの検証
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); Write-Host 'TargetPath:' $lnk.TargetPath; Write-Host 'Arguments:' $lnk.Arguments"

REM ファイル / フォルダ右クリックの「Code で開く」を登録
reg add "HKLM\SOFTWARE\Classes\*\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\Background\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%V\"" /f

REM --extensions-dir 付きで起動する code.cmd ラッパを作成
REM (%* を echo で書くと対話的 cmd で失われるため、PowerShell で [char]37+'*' を書き出す)
powershell -NoProfile -Command "$pct=[char]37; $q=[char]34; $c='@echo off'+[char]13+[char]10+$q+'C:\Program Files\Microsoft VS Code\bin\code.cmd'+$q+' --extensions-dir '+$q+'C:\ProgramData\vscode-extensions'+$q+' '+$pct+'*'+[char]13+[char]10; [IO.File]::WriteAllText('C:\ProgramData\vscode-extensions\vscode.cmd',$c,[Text.Encoding]::ASCII)"

REM 拡張機能のインストール
set "CODE=C:\Program Files\Microsoft VS Code\bin\code.cmd"
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot-chat
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.python
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.vscode-pylance
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.debugpy
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension MS-CEINTL.vscode-language-pack-ja
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension saoudrizwan.claude-dev
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension rust-lang.rust-analyzer
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension tamasfe.even-better-toml
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension anthropic.claude-code
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension almenon.arepl
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --list-extensions --show-versions
echo === セットアップ完了 ===

2. Python インタプリタの選択

同一マシンに複数の Python がインストールされている場合,VS Code で使用する Python 本体(インタプリタ:Python プログラムを解釈・実行するソフトウェア)を選択する必要がある.

  1. コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(Ctrl+Shift+P
  2. Python: Select Interpreter と入力する
  3. 表示される一覧から,使用する Python(例:C:\Program Files\Python312\python.exe)を選択する.

Python プログラム実行手順

[Windows での Python プログラム実行手順を見るには、ここをクリック]

Windows での Python 実行手順(Visual Studio Codeを使用)

プログラムファイルの作成と保存

  1. 左サイドバーの「エクスプローラー」アイコン(Ctrl+Shift+E)をクリックする
  2. 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する

    続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する

  3. フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
  4. ファイル名(例:aitask.py.ファイル名は何でも良い)を入力し Enter を押す.拡張子は .py(Python ファイルを示す拡張子)とする
  5. 実行したいコードを選択し,Ctrl+C でコピーする.VS Code のエディタ領域に Ctrl+V で貼り付ける
  6. Ctrl+S で保存する

プログラムの実行

  1. エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
  2. VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(print 関数の出力等)が表示される
  3. tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
  4. VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する

必要なライブラリのインストール

管理者権限コマンドプロンプトを起動する (手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。

次のコマンドを実行し、関連ライブラリをインストールする。

pip install -U --no-user transformers torch pillow opencv-python numpy mediapipe huggingface_hub

※ SigLIP 系モデルの読み込みには新しい transformers が必要であるため、-U を付けて最新版へ更新する。

概要

処理の流れ

  1. 1フレーム読み込み → MediaPipe で顔ランドマークを検出。
  2. ランドマークの外接矩形に余白率を掛けて拡張し、その領域を224×224に整形して顔クロップとする(画面には緑の矩形で表示)。
  3. 顔クロップをクリップバッファに追加し、16枚たまったら VideoMAE に渡して偽側確率を1つ得る。バッファは空にし、次の窓へ進む(窓は重ならない)。顔が検出されなかったフレームがあれば、その時点でバッファを破棄する。
  4. サンプリング間隔ごとに、同じ顔クロップを ViT と SigLIP に渡して偽側確率を得る。同時にオプティカルフロー・ランドマーク変位・輝度差を計算する。
  5. 停止時、静止画分類器は全サンプルの平均、動画分類器は全窓の平均を取り、しきい値と比較して動画1本につき3つの最終判定を出力する。併せて相関係数と一致状況を表示・保存する。

実行手順

  1. スクリプトを保存して実行する。コンソールに使用デバイス(cuda / cpu)とモデル読み込みの進行が表示される。初回は数分かかる。
  2. ウィンドウ上部のログに表示される3モデルの id2label を書き写す(第3章の表と照合する)。
  3. 入力を選ぶ。データセットを選ぶ場合は video/1.mp4(実写側)と deepfake/1.mp4(加工側)のように、同じ番号の対で実験すると比較しやすい。
  4. [解析開始]を押す。緑の矩形が顔を正しく囲んでいるかを目で確認する。囲めていない場合は一度停止し、余白率か顔検出の最小信頼度を調整して再実行する。
  5. [解析停止]を押す(またはファイル末尾まで待つ)。ログに最終判定が出力され、results/ に3つのファイルが保存される。

5. GUI で調整できるパラメータ

GUI 表示名既定値範囲(刻み)説明と影響
静止画分類のサンプリング間隔[フレーム]101〜60(1)ViT と SigLIP に画像を渡す間隔。小さくすると標本数が増えて平均が安定するが処理は遅くなる。低次特徴量もこの間隔で計算される。
顔クロップの余白率0.350.00〜1.00(0.05)ランドマーク外接矩形を上下左右に何割拡張するか。0に近いと顔の輪郭が切れ、大きいと背景が多く入り、背景の情報が判定に混入する。
偽側と判定するしきい値0.500.10〜0.90(0.05)動画全体の平均確率をこの値と比較して最終判定を決める。0.5 は便宜的な値であり、モデルの較正が保証された値ではない。
クリップ構成のフレーム間引き幅11〜4(1)VideoMAE 用の16枚を何フレームおきに集めるか。1なら16連続フレーム(約0.5秒相当)、4なら64フレーム分の時間幅を16枚で表現する。窓が覆う時間長が変わる。
Farnebäck の窓サイズ155〜45(2)オプティカルフロー推定の平均化窓。小さいと細かい動きに敏感、大きいと滑らかで頑健になる。
顔検出の最小信頼度0.500.10〜0.90(0.05)MediaPipe が顔と認めるしきい値。高くすると検出漏れが増え、クリップ窓が作られにくくなる。この値は[解析開始]時に反映される。
カメラ番号00〜3(1)カメラ入力を選んだときに使用するデバイス番号。
GUI で変更できない固定値。 クリップ長 16 フレームと入力解像度 224×224 は、VideoMAE の config.jsonnum_frames: 16, tubelet_size: 2, image_size: 224)および画像分類器2種の preprocessor_config.json が要求する仕様であり、変更するとモデルが動作しないため定数としている。窓のずらし幅(ストライド)は16フレーム、すなわち重なりなしで動画全体を覆う設定に固定している。

6. 出力される数値の定義

名称定義単位
ViT の Deepfake 確率顔クロップ1枚に対する softmax 出力のうち Deepfake クラスの値0〜1
SigLIP の Fake 確率顔クロップ1枚に対する softmax 出力のうち Fake クラスの値0〜1
VideoMAE の fake 確率顔クロップ16枚のクリップに対する softmax 出力のうち fake クラスの値0〜1
オプティカルフロー平均連続するサンプル間の224×224顔クロップ(グレースケール)に Farnebäck 法を適用し、全画素の移動量の大きさを平均した値画素/サンプル間隔
ランドマーク変位連続するサンプル間で、全ランドマーク点の原フレーム座標系での移動距離を平均した値画素/サンプル間隔
輝度左右差顔クロップのグレースケール画像における、左半分の平均輝度と右半分の平均輝度の差の絶対値0〜255
輝度上下差同じく上半分と下半分の平均輝度の差の絶対値0〜255

フローと変位は「サンプル間隔あたり」の量であるため、サンプリング間隔を変えると値の絶対値も変わる。異なる間隔で得た値を直接比較してはならない。

7. 実装上の注意点と限界

8. プログラムコード

# -*- coding: utf-8 -*-
"""
===============================================================================
 静止画分類器と動画分類器によるフェイク動画判定の比較(教材用プログラム・修正版)
-------------------------------------------------------------------------------
 ViT / SigLIP / VideoMAE を同一動画の同一顔クロップ列へ適用し、
 動画レベル判定の一致・不一致を比較する。

 修正版では、次のような「条件次第で止まる/数値が不正確になる」原因を
 例外処理やエラーチェックではなく、処理論理側で修正している。

 1. id2label のキーが文字列の場合でも、Tensor 添字として正しく使えるように
    偽側クラス番号を int に正規化する。
 2. 顔検出が途切れた後に、過去の顔クロップやランドマークと比較して
    オプティカルフロー・ランドマーク変位を計算しない。
 3. サンプリング間隔や VideoMAE 用フレーム間引きを、動画全体の frame_index
    の剰余ではなく「有効な顔区間内のカウンタ」で管理する。
 4. 解析中に GUI スライダーを動かしても、その1回の解析内で条件が混在しない
    ように、解析開始時の値を固定して使う。
 5. VideoMAE のクリップ CSV に開始フレーム・終了フレームを保存する。
 6. 結果ファイル名にマイクロ秒を含め、同一秒内の再実行で上書きしない。
===============================================================================
"""

import os
import csv
import urllib.request
from datetime import datetime

import tkinter as tk
from tkinter import ttk, filedialog

import numpy as np
import cv2
import torch
import mediapipe as mp
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision as mp_vision
from PIL import Image, ImageTk
from huggingface_hub import hf_hub_download
from transformers import (
    AutoImageProcessor,
    AutoModelForImageClassification,
    AutoModelForVideoClassification,
)

# ---------------------------------------------------------------------------
# 0. パスと定数
# ---------------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
RESULT_DIR = os.path.join(BASE_DIR, "results")
os.makedirs(RESULT_DIR, exist_ok=True)

MODEL_PATH = os.path.join(BASE_DIR, "face_landmarker.task")
MODEL_URL = (
    "https://storage.googleapis.com/mediapipe-models/face_landmarker/"
    "face_landmarker/float16/1/face_landmarker.task"
)

VIT_MODEL_ID = "prithivMLmods/Deep-Fake-Detector-v2-Model"
SIGLIP_MODEL_ID = "prithivMLmods/deepfake-detector-model-v1"
VIDEOMAE_MODEL_ID = "Ammar2k/videomae-base-finetuned-deepfake-subset"

HF_DATASET_REPO = "UniDataPro/deepfake-videos-dataset"
DATASET_FILES = [
    "video/1.mp4",
    "video/2.mp4",
    "video/3.mp4",
    "video/4.mp4",
    "video/5.MOV",
    "deepfake/1.mp4",
    "deepfake/2.mp4",
    "deepfake/3.mp4",
    "deepfake/4.mp4",
    "deepfake/5.mov",
]

CLIP_LENGTH = 16
CROP_SIZE = 224
DISPLAY_WIDTH = 560

DEFAULT_SAMPLE_INTERVAL = 10
DEFAULT_MARGIN_RATIO = 0.35
DEFAULT_THRESHOLD = 0.50
DEFAULT_CLIP_FRAME_STEP = 1
DEFAULT_FLOW_WINSIZE = 15
DEFAULT_FACE_CONFIDENCE = 0.50

# ---------------------------------------------------------------------------
# 1. 学習済みモデルの取得と読み込み
# ---------------------------------------------------------------------------
if not os.path.exists(MODEL_PATH):
    print("モデルファイルをダウンロードしています...")
    urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
    print("ダウンロードが完了しました。")

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
print("使用デバイス:", DEVICE)
print("学習済みモデルを読み込みます(初回はダウンロードのため数分かかります)...")

vit_processor = AutoImageProcessor.from_pretrained(VIT_MODEL_ID)
vit_model = AutoModelForImageClassification.from_pretrained(VIT_MODEL_ID).to(DEVICE).eval()

siglip_processor = AutoImageProcessor.from_pretrained(SIGLIP_MODEL_ID)
siglip_model = AutoModelForImageClassification.from_pretrained(SIGLIP_MODEL_ID).to(DEVICE).eval()

videomae_processor = AutoImageProcessor.from_pretrained(VIDEOMAE_MODEL_ID)
videomae_model = AutoModelForVideoClassification.from_pretrained(VIDEOMAE_MODEL_ID).to(DEVICE).eval()

print("読み込み完了")


def fake_class_index(id2label):
    """
    ラベル名に 'fake' を含むクラス番号を返す。

    Hugging Face の config.json では id2label のキーが JSON 由来の文字列として
    扱われる場合がある。Tensor の添字として使うため、ここで int に正規化する。
    """
    ordered_items = sorted(
        ((int(index), str(label)) for index, label in id2label.items()),
        key=lambda item: item[0],
    )
    for index, label in ordered_items:
        if "fake" in label.lower():
            return index


VIT_FAKE_INDEX = fake_class_index(vit_model.config.id2label)
SIGLIP_FAKE_INDEX = fake_class_index(siglip_model.config.id2label)
VIDEOMAE_FAKE_INDEX = fake_class_index(videomae_model.config.id2label)

# ---------------------------------------------------------------------------
# 2. 推論と計測の関数
# ---------------------------------------------------------------------------
@torch.no_grad()
def image_fake_probability(model, processor, fake_index, crop_rgb):
    """顔クロップ1枚に対する偽側クラスの確率を返す。"""
    inputs = processor(images=Image.fromarray(crop_rgb), return_tensors="pt").to(DEVICE)
    probabilities = torch.softmax(model(**inputs).logits, dim=-1)[0]
    return float(probabilities[int(fake_index)].item())


@torch.no_grad()
def clip_fake_probability(clip_frames_rgb):
    """顔クロップ16枚のクリップに対する偽側クラスの確率を返す。"""
    inputs = videomae_processor(videos=clip_frames_rgb, return_tensors="pt").to(DEVICE)
    probabilities = torch.softmax(videomae_model(**inputs).logits, dim=-1)[0]
    return float(probabilities[int(VIDEOMAE_FAKE_INDEX)].item())


def face_bounding_box(landmarks, frame_width, frame_height, margin_ratio):
    """ランドマークの外接矩形を余白率だけ拡張し、画面内に収めた矩形を返す。"""
    xs = np.array([landmark.x for landmark in landmarks]) * frame_width
    ys = np.array([landmark.y for landmark in landmarks]) * frame_height

    x_minimum, x_maximum = float(xs.min()), float(xs.max())
    y_minimum, y_maximum = float(ys.min()), float(ys.max())

    margin_x = (x_maximum - x_minimum) * margin_ratio
    margin_y = (y_maximum - y_minimum) * margin_ratio

    x1 = int(max(0.0, x_minimum - margin_x))
    y1 = int(max(0.0, y_minimum - margin_y))
    x2 = int(min(float(frame_width), x_maximum + margin_x))
    y2 = int(min(float(frame_height), y_maximum + margin_y))

    x1 = min(x1, frame_width - 2)
    y1 = min(y1, frame_height - 2)
    x2 = max(x2, x1 + 2)
    y2 = max(y2, y1 + 2)

    return x1, y1, x2, y2


def pearson_correlation(x_values, y_values):
    """Pearson の積率相関係数。算出できない場合は None を返す。"""
    if len(x_values) < 3:
        return None

    x = np.asarray(x_values, dtype=np.float64)
    y = np.asarray(y_values, dtype=np.float64)

    if x.std() < 1e-9 or y.std() < 1e-9:
        return None

    return float(np.corrcoef(x, y)[0, 1])


def format_correlation(value):
    if value is None:
        return "算出不可(標本数不足または分散がほぼ 0)"
    return "r = {:+.3f}".format(value)


def format_value(value, digits=3):
    if value is None:
        return "----"
    return "{:.{}f}".format(value, digits)


# ---------------------------------------------------------------------------
# 3. GUI アプリケーション
# ---------------------------------------------------------------------------
class ComparisonApplication:

    def __init__(self, root):
        self.root = root
        self.root.title("静止画分類器と動画分類器によるフェイク動画判定の比較(教材用)")

        self.capture = None
        self.landmarker = None
        self.running = False
        self.photo_image = None
        self.selected_file_path = ""
        self.source_description = ""

        self.reset_state()
        self.build_widgets()
        self.report_model_information()

    # -- 解析状態の初期化 --------------------------------------------------
    def reset_state(self):
        self.frame_index = 0

        self.clip_buffer = []
        self.clip_start_frame = 0
        self.clip_records = []

        self.previous_gray = None
        self.previous_landmarks = None

        self.frame_records = []
        self.no_face_frame_count = 0
        self.landmark_count_reported = False

        # 顔が連続して検出されている区間内で使うカウンタ
        self.sample_step_countdown = 0
        self.clip_step_countdown = 0

        # 解析開始時に固定されるパラメータ
        self.run_sample_interval = DEFAULT_SAMPLE_INTERVAL
        self.run_margin_ratio = DEFAULT_MARGIN_RATIO
        self.run_threshold = DEFAULT_THRESHOLD
        self.run_clip_frame_step = DEFAULT_CLIP_FRAME_STEP
        self.run_flow_winsize = DEFAULT_FLOW_WINSIZE
        self.run_face_confidence = DEFAULT_FACE_CONFIDENCE

    def capture_run_parameters(self):
        """解析1回分のパラメータを開始時点の値で固定する。"""
        self.run_sample_interval = int(self.sample_interval_variable.get())
        self.run_margin_ratio = float(self.margin_variable.get())
        self.run_threshold = float(self.threshold_variable.get())
        self.run_clip_frame_step = int(self.clip_frame_step_variable.get())
        self.run_flow_winsize = int(self.flow_winsize_variable.get())
        self.run_face_confidence = float(self.face_confidence_variable.get())

    # -- 画面構築 ----------------------------------------------------------
    def build_widgets(self):
        source_frame = ttk.LabelFrame(self.root, text="1. 入力動画の選択")
        source_frame.grid(row=0, column=0, columnspan=2, sticky="ew", padx=8, pady=6)

        self.source_variable = tk.StringVar(value="dataset")

        ttk.Radiobutton(
            source_frame,
            text="カメラ",
            variable=self.source_variable,
            value="camera",
        ).grid(row=0, column=0, sticky="w", padx=4)

        ttk.Label(source_frame, text="カメラ番号").grid(row=0, column=1, sticky="e")

        self.camera_index_variable = tk.IntVar(value=0)
        ttk.Spinbox(
            source_frame,
            from_=0,
            to=3,
            width=4,
            state="readonly",
            textvariable=self.camera_index_variable,
        ).grid(row=0, column=2, sticky="w")

        ttk.Radiobutton(
            source_frame,
            text="動画ファイル",
            variable=self.source_variable,
            value="file",
        ).grid(row=1, column=0, sticky="w", padx=4)

        ttk.Button(
            source_frame,
            text="ファイルを選択",
            command=self.choose_file,
        ).grid(row=1, column=1, columnspan=2, sticky="w")

        self.file_label = ttk.Label(source_frame, text="(未選択)")
        self.file_label.grid(row=1, column=3, sticky="w", padx=6)

        ttk.Radiobutton(
            source_frame,
            text="Hugging Face データセット",
            variable=self.source_variable,
            value="dataset",
        ).grid(row=2, column=0, sticky="w", padx=4)

        self.dataset_variable = tk.StringVar(value=DATASET_FILES[0])
        ttk.Combobox(
            source_frame,
            textvariable=self.dataset_variable,
            values=DATASET_FILES,
            state="readonly",
            width=16,
        ).grid(row=2, column=1, columnspan=2, sticky="w")

        ttk.Label(
            source_frame,
            text="UniDataPro/deepfake-videos-dataset  (video/ = 元動画, deepfake/ = 加工動画)",
        ).grid(row=2, column=3, sticky="w", padx=6)

        parameter_frame = ttk.LabelFrame(self.root, text="2. パラメータ")
        parameter_frame.grid(row=1, column=0, sticky="nsew", padx=8, pady=6)

        self.sample_interval_variable = tk.IntVar(value=DEFAULT_SAMPLE_INTERVAL)
        self.margin_variable = tk.DoubleVar(value=DEFAULT_MARGIN_RATIO)
        self.threshold_variable = tk.DoubleVar(value=DEFAULT_THRESHOLD)
        self.clip_frame_step_variable = tk.IntVar(value=DEFAULT_CLIP_FRAME_STEP)
        self.flow_winsize_variable = tk.IntVar(value=DEFAULT_FLOW_WINSIZE)
        self.face_confidence_variable = tk.DoubleVar(value=DEFAULT_FACE_CONFIDENCE)

        self.add_scale(
            parameter_frame,
            0,
            "静止画分類のサンプリング間隔[フレーム]",
            self.sample_interval_variable,
            1,
            60,
            1,
        )
        self.add_scale(
            parameter_frame,
            1,
            "顔クロップの余白率",
            self.margin_variable,
            0.0,
            1.0,
            0.05,
        )
        self.add_scale(
            parameter_frame,
            2,
            "偽側と判定するしきい値",
            self.threshold_variable,
            0.1,
            0.9,
            0.05,
        )
        self.add_scale(
            parameter_frame,
            3,
            "クリップ構成のフレーム間引き幅",
            self.clip_frame_step_variable,
            1,
            4,
            1,
        )
        self.add_scale(
            parameter_frame,
            4,
            "Farnebäck 法の窓サイズ[画素]",
            self.flow_winsize_variable,
            5,
            45,
            2,
        )
        self.add_scale(
            parameter_frame,
            5,
            "顔検出の最小信頼度",
            self.face_confidence_variable,
            0.1,
            0.9,
            0.05,
        )

        ttk.Label(
            parameter_frame,
            text="クリップ長 16 フレーム / 入力 224x224 画素はモデル仕様のため固定",
        ).grid(row=6, column=0, columnspan=2, sticky="w", pady=4)

        ttk.Label(
            parameter_frame,
            text="各パラメータは解析開始時に固定され、解析中の変更は次回開始時に反映",
        ).grid(row=7, column=0, columnspan=2, sticky="w", pady=4)

        control_frame = ttk.LabelFrame(self.root, text="3. 操作")
        control_frame.grid(row=2, column=0, sticky="ew", padx=8, pady=6)

        self.start_button = ttk.Button(
            control_frame,
            text="解析開始(取り込み開始)",
            command=self.start_analysis,
        )
        self.start_button.grid(row=0, column=0, padx=6, pady=6)

        self.stop_button = ttk.Button(
            control_frame,
            text="解析停止(取り込み停止)",
            command=self.stop_analysis,
        )
        self.stop_button.grid(row=0, column=1, padx=6, pady=6)
        self.stop_button.state(["disabled"])

        video_frame = ttk.LabelFrame(self.root, text="4. 入力映像と顔クロップ領域(緑枠)")
        video_frame.grid(row=1, column=1, rowspan=2, sticky="n", padx=8, pady=6)

        self.video_label = ttk.Label(video_frame)
        self.video_label.grid(row=0, column=0, padx=4, pady=4)

        log_frame = ttk.LabelFrame(self.root, text="5. 解析ログと最終判定")
        log_frame.grid(row=3, column=0, columnspan=2, sticky="nsew", padx=8, pady=6)

        self.log_text = tk.Text(log_frame, width=118, height=16, wrap="none")
        self.log_text.grid(row=0, column=0, sticky="nsew")

        scrollbar = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview)
        scrollbar.grid(row=0, column=1, sticky="ns")
        self.log_text.configure(yscrollcommand=scrollbar.set)

    def add_scale(self, parent, row, text, variable, minimum, maximum, resolution):
        ttk.Label(parent, text=text).grid(row=row, column=0, sticky="w", padx=4)
        tk.Scale(
            parent,
            variable=variable,
            from_=minimum,
            to=maximum,
            resolution=resolution,
            orient=tk.HORIZONTAL,
            length=240,
        ).grid(row=row, column=1, sticky="w", padx=4)

    # -- ログ出力 ----------------------------------------------------------
    def log(self, message):
        self.log_text.insert(tk.END, message + "\n")
        self.log_text.see(tk.END)
        print(message)

    def report_model_information(self):
        self.log("使用デバイス: {}".format(DEVICE))
        self.log("--- 各モデルのクラス定義(config.json の id2label)---")
        self.log(
            "静止画分類器 A  ViT    : {}  → 偽側とするクラス番号 {}".format(
                dict(vit_model.config.id2label), VIT_FAKE_INDEX
            )
        )
        self.log(
            "静止画分類器 B  SigLIP : {}  → 偽側とするクラス番号 {}".format(
                dict(siglip_model.config.id2label), SIGLIP_FAKE_INDEX
            )
        )
        self.log(
            "動画分類器      VideoMAE: {}  → 偽側とするクラス番号 {}".format(
                dict(videomae_model.config.id2label), VIDEOMAE_FAKE_INDEX
            )
        )
        self.log(
            "VideoMAE の入力仕様: num_frames = {}, tubelet_size = {}, image_size = {}".format(
                videomae_model.config.num_frames,
                videomae_model.config.tubelet_size,
                videomae_model.config.image_size,
            )
        )
        self.log("結果の保存先: {}".format(RESULT_DIR))
        self.log("")

    # -- 入力の選択 --------------------------------------------------------
    def choose_file(self):
        path = filedialog.askopenfilename(
            title="解析する動画ファイルを選択",
            filetypes=[
                ("動画ファイル", "*.mp4 *.mov *.MOV *.avi *.mkv"),
                ("すべて", "*.*"),
            ],
        )
        if path != "":
            self.selected_file_path = path
            self.file_label.configure(text=os.path.basename(path))
            self.source_variable.set("file")

    # -- 解析の開始 --------------------------------------------------------
    def start_analysis(self):
        if self.running:
            return

        source = self.source_variable.get()
        if source == "file" and self.selected_file_path == "":
            self.log("動画ファイルが選択されていません。[ファイルを選択]を押してください。")
            return

        self.reset_state()
        self.capture_run_parameters()

        if source == "camera":
            camera_index = int(self.camera_index_variable.get())
            backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
            self.capture = cv2.VideoCapture(camera_index, backend)
            description = "カメラ {}".format(camera_index)

        elif source == "file":
            self.capture = cv2.VideoCapture(self.selected_file_path)
            description = self.selected_file_path

        else:
            self.log("データセットから動画を取得します(初回のみダウンロード)...")
            self.root.update()
            local_path = hf_hub_download(
                repo_id=HF_DATASET_REPO,
                repo_type="dataset",
                filename=self.dataset_variable.get(),
            )
            self.capture = cv2.VideoCapture(local_path)
            description = "{} : {}".format(HF_DATASET_REPO, self.dataset_variable.get())

        if not self.capture.isOpened():
            self.log("動画を開けませんでした。入力の指定を確認してください。")
            self.capture.release()
            self.capture = None
            return

        self.source_description = description

        landmarker_options = mp_vision.FaceLandmarkerOptions(
            base_options=mp_python.BaseOptions(model_asset_path=MODEL_PATH),
            running_mode=mp_vision.RunningMode.IMAGE,
            num_faces=1,
            min_face_detection_confidence=self.run_face_confidence,
        )
        self.landmarker = mp_vision.FaceLandmarker.create_from_options(landmarker_options)

        self.running = True
        self.start_button.state(["disabled"])
        self.stop_button.state(["!disabled"])

        self.log("=== 解析開始: {} ===".format(self.source_description))
        self.log(
            "サンプリング間隔 {} / 余白率 {:.2f} / しきい値 {:.2f} / "
            "間引き幅 {} / 窓サイズ {} / 顔検出信頼度 {:.2f}".format(
                self.run_sample_interval,
                self.run_margin_ratio,
                self.run_threshold,
                self.run_clip_frame_step,
                self.run_flow_winsize,
                self.run_face_confidence,
            )
        )
        self.log("※ 上記パラメータはこの解析中は固定されます。")
        self.root.after(1, self.process_next_frame)

    # -- 顔が途切れたときの時間方向状態のリセット --------------------------
    def reset_temporal_state_after_face_loss(self):
        """
        顔検出が途切れた場合、VideoMAE の未完成クリップだけでなく、
        オプティカルフローとランドマーク変位の比較元も捨てる。

        これにより、顔が見えていなかった区間をまたいで
        「連続サンプル」として扱ってしまう不正確な計測を防ぐ。
        """
        self.clip_buffer = []
        self.clip_start_frame = 0

        self.previous_gray = None
        self.previous_landmarks = None

        self.sample_step_countdown = 0
        self.clip_step_countdown = 0

    # -- サンプリング用カウンタ --------------------------------------------
    def take_static_sample_now(self):
        """
        静止画分類器に渡すサンプルを取るタイミングを返す。
        frame_index の剰余ではなく、顔が連続して検出されている区間内の
        カウントで間隔を管理する。
        """
        if self.sample_step_countdown == 0:
            self.sample_step_countdown = self.run_sample_interval - 1
            return True

        self.sample_step_countdown -= 1
        return False

    def take_clip_frame_now(self):
        """
        VideoMAE クリップに追加するフレームを取るタイミングを返す。
        顔が連続して検出されている区間内で、指定間引き幅ごとに1枚取る。
        """
        if self.clip_step_countdown == 0:
            self.clip_step_countdown = self.run_clip_frame_step - 1
            return True

        self.clip_step_countdown -= 1
        return False

    # -- 1フレーム分の処理 ------------------------------------------------
    def process_next_frame(self):
        if not self.running:
            return

        succeeded, frame_bgr = self.capture.read()
        if not succeeded:
            self.log("入力の終端に到達しました。")
            self.finish_analysis()
            return

        self.frame_index += 1

        frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
        frame_height, frame_width = frame_rgb.shape[:2]

        mediapipe_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=frame_rgb)
        detection = self.landmarker.detect(mediapipe_image)

        box = None

        if len(detection.face_landmarks) == 0:
            self.no_face_frame_count += 1
            self.reset_temporal_state_after_face_loss()

        else:
            landmarks = detection.face_landmarks[0]

            if not self.landmark_count_reported:
                self.log("MediaPipe が返した顔ランドマークの点数: {}".format(len(landmarks)))
                self.landmark_count_reported = True

            box = face_bounding_box(
                landmarks,
                frame_width,
                frame_height,
                self.run_margin_ratio,
            )
            x1, y1, x2, y2 = box

            crop_rgb = cv2.resize(
                frame_rgb[y1:y2, x1:x2],
                (CROP_SIZE, CROP_SIZE),
            )

            self.update_clip_buffer(crop_rgb)

            if self.take_static_sample_now():
                self.analyze_sample(crop_rgb, landmarks, frame_width, frame_height)

        self.update_display(frame_bgr, box)
        self.root.after(1, self.process_next_frame)

    # -- VideoMAE 用クリップの更新 ----------------------------------------
    def update_clip_buffer(self, crop_rgb):
        if not self.take_clip_frame_now():
            return

        if len(self.clip_buffer) == 0:
            self.clip_start_frame = self.frame_index

        self.clip_buffer.append(crop_rgb)

        if len(self.clip_buffer) == CLIP_LENGTH:
            probability = clip_fake_probability(self.clip_buffer)

            record = {
                "start_frame": self.clip_start_frame,
                "end_frame": self.frame_index,
                "probability": probability,
            }
            self.clip_records.append(record)

            self.log(
                "[窓 {:3d}]フレーム {:5d}-{:5d} : VideoMAE fake 確率 = {:.3f}".format(
                    len(self.clip_records),
                    record["start_frame"],
                    record["end_frame"],
                    record["probability"],
                )
            )

            self.clip_buffer = []
            self.clip_start_frame = 0

    # -- サンプル1点の解析 -------------------------------------------------
    def analyze_sample(self, crop_rgb, landmarks, frame_width, frame_height):
        vit_probability = image_fake_probability(
            vit_model,
            vit_processor,
            VIT_FAKE_INDEX,
            crop_rgb,
        )
        siglip_probability = image_fake_probability(
            siglip_model,
            siglip_processor,
            SIGLIP_FAKE_INDEX,
            crop_rgb,
        )

        gray = cv2.cvtColor(crop_rgb, cv2.COLOR_RGB2GRAY)

        flow_mean = None
        if self.previous_gray is not None:
            flow = cv2.calcOpticalFlowFarneback(
                self.previous_gray,
                gray,
                None,
                0.5,
                3,
                self.run_flow_winsize,
                3,
                5,
                1.2,
                0,
            )
            flow_mean = float(np.mean(np.sqrt(flow[..., 0] ** 2 + flow[..., 1] ** 2)))

        self.previous_gray = gray

        current_landmarks = np.array(
            [[landmark.x * frame_width, landmark.y * frame_height] for landmark in landmarks]
        )

        displacement = None
        if self.previous_landmarks is not None:
            if self.previous_landmarks.shape == current_landmarks.shape:
                displacement = float(
                    np.mean(
                        np.linalg.norm(
                            current_landmarks - self.previous_landmarks,
                            axis=1,
                        )
                    )
                )

        self.previous_landmarks = current_landmarks

        half = CROP_SIZE // 2
        luminance_left_right = float(abs(gray[:, :half].mean() - gray[:, half:].mean()))
        luminance_top_bottom = float(abs(gray[:half, :].mean() - gray[half:, :].mean()))

        self.frame_records.append(
            {
                "frame": self.frame_index,
                "vit": vit_probability,
                "siglip": siglip_probability,
                "flow": flow_mean,
                "displacement": displacement,
                "luminance_left_right": luminance_left_right,
                "luminance_top_bottom": luminance_top_bottom,
            }
        )

        self.log(
            (
                "[フレーム {:5d}]ViT Deepfake 確率 = {:.3f} / "
                "SigLIP Fake 確率 = {:.3f} / "
                "フロー平均 = {} 画素 / ランドマーク変位 = {} 画素 / "
                "輝度左右差 = {} / 輝度上下差 = {}"
            ).format(
                self.frame_index,
                vit_probability,
                siglip_probability,
                format_value(flow_mean),
                format_value(displacement),
                format_value(luminance_left_right, 1),
                format_value(luminance_top_bottom, 1),
            )
        )

    # -- 映像表示 ----------------------------------------------------------
    def update_display(self, frame_bgr, box):
        if box is not None:
            x1, y1, x2, y2 = box
            cv2.rectangle(frame_bgr, (x1, y1), (x2, y2), (0, 255, 0), 2)
            cv2.putText(
                frame_bgr,
                "face crop 224x224",
                (x1, max(20, y1 - 8)),
                cv2.FONT_HERSHEY_SIMPLEX,
                0.55,
                (0, 255, 0),
                2,
            )

        rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
        height, width = rgb.shape[:2]
        display_height = int(height * DISPLAY_WIDTH / width)
        display_image = cv2.resize(rgb, (DISPLAY_WIDTH, display_height))

        self.photo_image = ImageTk.PhotoImage(Image.fromarray(display_image))
        self.video_label.configure(image=self.photo_image)

    # -- 停止と後処理 ------------------------------------------------------
    def stop_analysis(self):
        if not self.running:
            return

        self.log("停止ボタンが押されました。")
        self.finish_analysis()

    def finish_analysis(self):
        self.running = False

        if self.capture is not None:
            self.capture.release()
            self.capture = None

        if self.landmarker is not None:
            self.landmarker.close()
            self.landmarker = None

        self.start_button.state(["!disabled"])
        self.stop_button.state(["disabled"])

        self.write_report()

    # -- 動画レベルの最終判定 ----------------------------------------------
    def describe_decision(self, probabilities, fake_label, real_label):
        if len(probabilities) == 0:
            return "判定不可(有効な標本が 0 件)"

        mean_value = float(np.mean(probabilities))
        decision = fake_label if mean_value >= self.run_threshold else real_label

        return "標本数 {:d} / 平均確率 {:.3f} / しきい値 {:.2f} → 判定: {}".format(
            len(probabilities),
            mean_value,
            self.run_threshold,
            decision,
        )

    # -- レポートと CSV の保存 --------------------------------------------
    def write_report(self):
        threshold = self.run_threshold

        vit_probabilities = [record["vit"] for record in self.frame_records]
        siglip_probabilities = [record["siglip"] for record in self.frame_records]
        videomae_probabilities = [record["probability"] for record in self.clip_records]

        lines = []
        lines.append("==================== 動画レベルの最終判定 ====================")
        lines.append("入力: {}".format(self.source_description))
        lines.append(
            "読み込んだ総フレーム数: {} / 顔が検出されなかったフレーム数: {}".format(
                self.frame_index,
                self.no_face_frame_count,
            )
        )
        lines.append(
            "解析パラメータ: サンプリング間隔 {} / 余白率 {:.2f} / しきい値 {:.2f} / "
            "間引き幅 {} / Farnebäck 窓サイズ {} / 顔検出信頼度 {:.2f}".format(
                self.run_sample_interval,
                self.run_margin_ratio,
                self.run_threshold,
                self.run_clip_frame_step,
                self.run_flow_winsize,
                self.run_face_confidence,
            )
        )
        lines.append("")

        lines.append("[静止画分類器 A] ViT  Deep-Fake-Detector-v2-Model(Deepfake 側の確率)")
        lines.append("    " + self.describe_decision(vit_probabilities, "Deepfake", "Realism"))

        lines.append("[静止画分類器 B] SigLIP  deepfake-detector-model-v1(Fake 側の確率)")
        lines.append("    " + self.describe_decision(siglip_probabilities, "Fake", "Real"))

        lines.append("[動画分類器] VideoMAE  videomae-base-finetuned-deepfake-subset(fake 側の確率)")
        lines.append("    " + self.describe_decision(videomae_probabilities, "fake", "real"))
        lines.append("")

        decisions = []
        for probabilities in [
            vit_probabilities,
            siglip_probabilities,
            videomae_probabilities,
        ]:
            if len(probabilities) > 0:
                decisions.append(float(np.mean(probabilities)) >= threshold)

        if len(decisions) == 3:
            if len(set(decisions)) == 1:
                lines.append("3 手法の判定は一致した。")
            else:
                lines.append("3 手法の判定は一致しなかった。どの手法が何を根拠にしているか考察すること。")
        else:
            lines.append("判定できなかった手法があるため、一致・不一致は評価しない。")

        lines.append("")
        lines.append("---- 静止画分類器の出力確率と低次画像特徴量の相関(Pearson の積率相関係数)----")

        cue_list = [
            ("flow", "オプティカルフロー平均"),
            ("displacement", "ランドマーク変位"),
            ("luminance_left_right", "輝度左右差"),
            ("luminance_top_bottom", "輝度上下差"),
        ]
        model_list = [
            ("vit", "ViT の Deepfake 確率"),
            ("siglip", "SigLIP の Fake 確率"),
        ]

        for cue_key, cue_name in cue_list:
            for model_key, model_name in model_list:
                pairs = [
                    (record[model_key], record[cue_key])
                    for record in self.frame_records
                    if record[cue_key] is not None
                ]
                value = pearson_correlation(
                    [pair[0] for pair in pairs],
                    [pair[1] for pair in pairs],
                )
                lines.append(
                    "    {} と {}: {}(標本数 {})".format(
                        model_name,
                        cue_name,
                        format_correlation(value),
                        len(pairs),
                    )
                )

        lines.append("    ※ VideoMAE は 16 フレームの窓単位で出力するため、")
        lines.append("       フレーム単位の低次特徴量とは標本の対応が取れない。")
        lines.append("       そのため、ここでは相関は算出していない。")
        lines.append("=============================================================")

        for line in lines:
            self.log(line)

        stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
        frames_path = os.path.join(RESULT_DIR, "frames_{}.csv".format(stamp))
        clips_path = os.path.join(RESULT_DIR, "clips_{}.csv".format(stamp))
        summary_path = os.path.join(RESULT_DIR, "summary_{}.txt".format(stamp))

        with open(frames_path, "w", newline="", encoding="utf-8-sig") as frames_file:
            writer = csv.writer(frames_file)
            writer.writerow(
                [
                    "frame_index",
                    "vit_deepfake_probability",
                    "siglip_fake_probability",
                    "optical_flow_mean_pixel",
                    "landmark_displacement_pixel",
                    "luminance_left_right_difference",
                    "luminance_top_bottom_difference",
                ]
            )

            for record in self.frame_records:
                writer.writerow(
                    [
                        record["frame"],
                        "{:.6f}".format(record["vit"]),
                        "{:.6f}".format(record["siglip"]),
                        "" if record["flow"] is None else "{:.6f}".format(record["flow"]),
                        ""
                        if record["displacement"] is None
                        else "{:.6f}".format(record["displacement"]),
                        "{:.6f}".format(record["luminance_left_right"]),
                        "{:.6f}".format(record["luminance_top_bottom"]),
                    ]
                )

        with open(clips_path, "w", newline="", encoding="utf-8-sig") as clips_file:
            writer = csv.writer(clips_file)
            writer.writerow(
                [
                    "clip_index",
                    "start_frame",
                    "end_frame",
                    "videomae_fake_probability",
                ]
            )

            for index, record in enumerate(self.clip_records, start=1):
                writer.writerow(
                    [
                        index,
                        record["start_frame"],
                        record["end_frame"],
                        "{:.6f}".format(record["probability"]),
                    ]
                )

        with open(summary_path, "w", encoding="utf-8") as summary_file:
            summary_file.write("\n".join(lines))

        self.log("保存しました: {}".format(frames_path))
        self.log("保存しました: {}".format(clips_path))
        self.log("保存しました: {}".format(summary_path))
        self.log("")

    # -- 終了処理 ----------------------------------------------------------
    def on_close(self):
        if self.running:
            self.finish_analysis()
        self.root.destroy()


# ---------------------------------------------------------------------------
# 4. 起動
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    window = tk.Tk()
    application = ComparisonApplication(window)
    window.protocol("WM_DELETE_WINDOW", application.on_close)
    window.mainloop()

9. ヒント・実験のバリエーション・考察のポイント

9.1 実験を始める前のヒント

  1. 起動直後にログへ表示される3モデルの id2label を必ず書き写す。Deepfake が index 1、Fakefake が index 0 であることを確認してから実験に入る。
  2. まず deepfake/1.mp4(加工側)と video/1.mp4(元動画側)を同じパラメータで1回ずつ実行し、平均確率が期待どおりの向きに動くかを確かめる。向きが逆なら、そのモデルはこの映像に対して有効でないという貴重な観察結果である。
  3. 緑の矩形が顔を正しく囲んでいるかを最初の数秒で目視する。囲めていない状態のデータは解析しても意味がない。
  4. ログ末尾の「窓」の個数を確認する。窓が数個しかないときは VideoMAE の平均が不安定なので、間引き幅を1に戻すか、より長い動画を使う。
  5. 結果は results/ に時刻付きで保存される。パラメータをメモに残し、どのファイルがどの条件かを対応づけておく。

9.2 実験のバリエーション

  1. サンプリング間隔を 5 / 10 / 30 と変える。静止画分類器の平均確率と最終判定が変わるか、標本数が減ると結論がぶれるかを見る。
  2. 顔クロップの余白率を 0.05 / 0.35 / 0.80 と変える。背景が多く入ると判定がどう動くか。背景の情報が判定に影響しているなら、それは顔の偽造痕跡以外を見ている証拠になる。
  3. クリップ構成のフレーム間引き幅を 1 と 4 で比較する。同じ16枚でも、覆う時間長が4倍になると VideoMAE の確率が変わるかを確かめる。
  4. しきい値を 0.3 / 0.5 / 0.7 と変え、結論が反転する境界を探す。平均確率が 0.5 付近の動画ほど結論が脆いことを確認する。
  5. カメラ入力で自分の顔を撮影する(実写であることが確実な入力)。3モデルが実写側に判定するか、照明を暗くしたり手で顔を半分隠したりすると判定が崩れるかを観察する。

9.3 考察のポイント

  1. 3手法の判定が一致した場合と食い違った場合で、フレーム単位・窓単位の確率の揺れ方に違いはあるか。平均だけでなくばらつきを見て論じること。
  2. 静止画分類器は時間情報を持たないのに、動画1本の平均を取れば動画判定になる。これで十分な場合と不十分な場合はどのような偽造手法のときか。
  3. 相関係数がほぼ0なら、その分類器の判断はフローや輝度差といった低次量では説明できない。では何を見ている可能性があるか、モデルの入力(224×224の顔クロップのみ)という制約から推論せよ。
  4. VideoMAE は学習データが非公開である。精度が不明なモデルの出力を、他の2モデルと同列に扱ってよいか。研究倫理・再現性の観点から論じること。
  5. 本プログラムは顔領域だけを見ている。第2章 No.10 の UNITE のように背景や全画面生成を対象とする手法が必要になるのはどのような場面か、実際の偽情報の流通形態と結びつけて考えること。
まとめ。 本教材の核心は「1枚だけを見る分類器」と「16フレームを見る分類器」を同じ映像に同時にかけ、動画1本の結論として突き合わせる点にある。フレーム単位・窓単位の数値はその結論に至る途中経過であり、最後に必ず1本の判定へ集約される。数値を眺めて終わりにせず、判定が食い違った理由を、各モデルが入力として何を受け取っているかという構造から説明できるようになることを目指してほしい。