画像からの異常検知(統計モデル(PaDiM)・メモリバンク(PatchCore)・基盤モデル特徴(DINOv2最近傍法)の比較実験ツール)

概要

画像からの異常検知の予備実験資料。統計モデル(PaDiM)、メモリバンク(PatchCore)、基盤モデル特徴(DINOv2特徴を用いた最近傍法)の3方式を、学習済み特徴抽出器と正常画像の登録のみで実装し、ウェブカメラ画像で連続比較する手順・コード・考察の視点をまとめる。

本教材の目的は、論文ベンチマークの再現ではなく、正常状態を登録してから、その状態からの見た目の変化を異常マップ(画像を格子状の小領域に分割し、領域ごとの異常度を並べたもの)として観察することである。

目次

1. 目的と前提

テーマは画像からの異常検知である。対象は、物体、床面、机上、壁面などの静的な被写体とする。人間や動体そのものを検出することは目的としないため、動きの検出に依存しない外観ベースの異常検知手法を用いる。

ここでいう「異常」とは、「故障」「危険」「不良品」を直接意味するものではない。本教材では、正常画像として登録した状態から、画像上の見た目が変化した部分を異常として扱う。したがって、異物、傷、汚れだけでなく、照明の変化、カメラ位置の変化、影、反射、ピントの変化も異常として反応する場合がある。

運用上は、正常登録時と検知実行時のいずれにおいても、カメラと被写体をできるだけ静止させて使用する。人が写り込んだ場合、人を認識しているのではなく、登録時と異なる見た目の変化として反応する可能性がある。

1.1 この実験で確認できること

1.2 この実験だけでは確認できないこと

本教材では正解ラベル付きデータセットを使わないため、AUROC、AP、PRO(いずれも正解ラベルを用いて検出性能を数値化する評価指標)などの評価指標は計算しない。異常マップの位置、色の変化、スコア比の増減を観察し、実験条件と対応づけて考察する。

2. 手法候補の一覧

静的な被写体の外観異常を検知できる画像異常検知手法について、代表的な以下の7件を候補とした。発表年や会議名は文献上の位置づけを示すための情報であり、本教材の実験では、実装対象の3方式のみを扱う。

No.手法名代表的な出典分類概要
1PaDiMICPR 2020/2021統計モデルパッチ(画像や特徴マップを格子状に分割した小領域)の位置ごとに正常特徴を多変量ガウス分布でモデル化し、マハラノビス距離(特徴の分散と相関を考慮して、平均からの離れ具合を測る距離)で異常度を算出する。
2PatchCoreCVPR 2022メモリバンク正常パッチ特徴をメモリバンク(正常時の特徴をそのまま保存しておく集合)化し、最近傍距離で異常度を算出する。コアセット選択(元の集合の広がりを保つように代表点を選び出す処理)によりメモリ量を削減する。
3EfficientADWACV 2024蒸留+オートエンコーダ軽量な教師・生徒構成(学習済みモデルの出力を教師として別のモデルを学習させる蒸留の構成)とオートエンコーダ(入力を低次元に圧縮してから復元するネットワーク)を組み合わせ、高速な異常検知を目指す手法である。
4AnomalyCLIPICLR 2024視覚言語モデル視覚言語モデル(画像と自然言語を共通の特徴空間で扱うモデル)CLIPを異常検知に適応させ、画像とテキスト表現を用いて異常を扱う手法である。
5AnomalyDINOWACV 2025基盤モデル特徴+最近傍DINOv2などの自己教師あり(正解ラベルを使わず、データ自身から学習の手がかりを作る学習方式)基盤モデル(大規模データで事前学習し、多様な課題に転用できるモデル)の特徴を用いて、正常参照画像とのパッチ特徴の違いから異常を検知する考え方を持つ。
6DinomalyCVPR 2025再構成(Transformer)DINOv2特徴の再構成に基づく異常検知手法である。
7INP-FormerCVPR 2025画像内プロトタイプ画像内の特徴からプロトタイプ(その領域を代表する特徴ベクトル)を作り、それを用いて異常を検知する考え方を持つ手法である。

3. 手法の選定

実行でき、仕組みを比較しやすく、追加のネットワーク学習を必要としない方式として、PaDiM、PatchCore、DINOv2特徴を用いた最近傍法の3方式を実装対象とした。

基準PaDiMPatchCoreDINOv2特徴を用いた最近傍法
基準1:教育的有用性ガウス分布とマハラノビス距離という統計の基礎概念で説明できる。「正常例を記憶し、最も近い正常例からの距離を測る」という最近傍法の考え方で説明できる。PatchCoreと同じ最近傍法の考え方を使いながら、特徴抽出器をCNNから基盤モデルへ変えたときの反応を比較できる。
基準2:実験しやすさtorchvisionのImageNet(大規模な画像分類用データセット)学習済みResNet-18を利用できる。PaDiMと同じResNet-18特徴を利用できる。torch.hubからDINOv2 ViT-S/14(画像を14×14画素のパッチに分割して扱うVision Transformerの小規模構成)を取得できる。
基準3:追加学習の有無対象データでのネットワーク学習は行わない。正常画像から統計量を推定する。対象データでのネットワーク学習は行わない。正常パッチ特徴のメモリバンクを作る。対象データでのネットワーク学習は行わない。正常パッチ特徴を参照メモリとして保持する。

選定しなかった候補の主な理由は次のとおりである。EfficientADやDinomalyは通常、対象データを用いた学習処理を含む。AnomalyCLIPは視覚言語モデルやプロンプト学習(モデルへ与える指示文の表現を学習で最適化する手法)の扱いが入り、単一ファイルでの体験実験にはやや重い。INP-Formerは有用な研究テーマであるが、実装規模や依存関係の点で、本教材の目的である「ウェブカメラで3方式をすぐ比較する実験」には採用しなかった。

なお、本教材のDINOv2特徴を用いた最近傍法は、AnomalyDINOの考え方に関連する「基盤モデル特徴+正常参照パッチとの最近傍比較」を体験するための実装である。AnomalyDINOの公式実装や論文実験の完全再現ではない。

4. プログラムの仕様

4.1 スコア比の重要な注意

スコア比は、各方式ごとに、較正用正常画像の最大スコアを100%として計算する相対値である。したがって、PaDiMのスコア比180%とPatchCoreのスコア比180%が、同じ大きさの異常を意味するわけではない。

手法間の数値の大小だけで優劣を判断してはいけない。同じ登録条件・同じ被写体・同じ変化に対して、異常マップの場所、広がり、スコア比の増減、判定の変化を観察する。

4.2 異常マップの重要な注意

異常マップは32×32のパッチ単位で計算し、表示時には見やすくするために拡大とぼかしを行っている。そのため、赤く表示された領域は「その付近の異常度が高い」ことを示すが、画素単位で正確な異常境界を示しているわけではない。

赤色は「絶対的に危険」という意味ではない。登録時に得た正常基準と現在のしきい値に対して、異常度が高いことを意味する。

5. 実行方法

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 ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する

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

PyTorchとtorchvisionは、上記のPyTorchインストール手順で導入済みであることを前提とする。そのうえで、以下のライブラリを追加でインストールする。

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

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

pip install -U --no-user opencv-python numpy pillow

実行

python anomaly_detection_demo.py

操作手順

  1. 初回実行時には学習済みモデルの自動ダウンロードが行われるため、インターネット接続が必要である。
  2. 左端のライブ映像を見ながら、カメラを静的な被写体に向ける。
  3. カメラと被写体をできるだけ固定し、照明が大きく変わらない状態にする。
  4. 「正常画像の登録」ボタンを押し、正常状態を撮影・登録する。続けて基準値の較正用画像が5枚撮影される。この間は、カメラと被写体を動かさない。
  5. 登録が完了すると、オレンジ色の「処理開始」ボタンが押せるようになる。これを押すと連続検知が始まる。
  6. 連続検知の実行中に、異物を置く、物体を少し動かす、照明を変えるなどの変化を加え、3方式の異常マップとスコア比がどのように変化するかを観察する。
  7. 「処理停止」ボタンで処理を止め、最後の検知結果を見比べる。被写体やカメラ位置を変えたときは、あらためて「正常画像の登録」からやり直す。

6. 調整可能なパラメータ

スライダー初期値(推奨)範囲説明
正常画像の枚数205〜50正常状態として登録する画像の枚数。多いほど正常状態のばらつきを反映しやすいが、登録時間とメモリ使用量も増える。このほかに、基準値の較正用として5枚が追加撮影される。
PaDiM 使用特徴次元数10010〜150ランダムに選ぶ特徴次元数。大きくすると情報量は増える可能性があるが、特徴次元数に比例して登録時と検知時の計算量が増える。正常画像の枚数が少ない場合、大きすぎる次元数では正則化(分散共分散に小さな値を加えて計算を安定させる処理)の影響が強くなる。
PatchCore サンプリング率10 %1〜10 %正常パッチ特徴のうち、メモリバンクに保持する割合。大きいほど正常パターンを多く保持できるが、登録時間と検知時の近傍探索コストが増える。
PatchCore 近傍数 k11〜9異常度算出に用いる近傍数。k=1では最も近い正常パッチとの距離を用いる。kを大きくすると複数の近傍距離の平均を用いるため、局所的な外れ値への反応が変化する。
異常判定しきい値150 %100〜300 %較正用正常画像の最大スコアを100%とし、現在のスコア比がこの値を超えた場合に「異常あり」と判定する。異常マップの色もこのしきい値で正規化される。

DINOv2特徴を用いた最近傍法は、最近傍1点を用いる構成としているため、近傍数に関するスライダーは持たない。

7. 実装上の扱いと原論文との差異

本教材は、PaDiM、PatchCore、DINOv2特徴を用いた最近傍法の基本的な考え方を比較体験するための実装である。公式実装や論文実験の完全再現ではない。特に、ウェブカメラでリアルタイムに近い操作感を得るため、入力解像度、特徴マップの空間サイズ、特徴抽出器、画像レベルスコアの扱いを教材向けに調整している。

項目本実装での扱い説明
PaDiMResNet-18のlayer1〜3を使い、32×32のパッチ特徴にそろえた上で、パッチ位置ごとのガウス分布を推定する。位置ごとに「正常な特徴の分布」を持ち、そこから離れた特徴を異常とみなす方式として理解する。
PatchCoreResNet-18のlayer2〜3を使い、局所平均プーリング(近傍のパッチ特徴を平均してまとめる処理)を行った後、正常パッチ特徴をコアセット選択してメモリバンク化する。正常パッチを記憶し、最も近い正常パッチからの距離で異常度を測る方式として理解する。
DINOv2特徴最近傍法DINOv2 ViT-S/14のパッチ特徴を正規化し、正常画像の全パッチ特徴とのコサイン類似度(2つのベクトルのなす角の余弦で測る類似度)から異常度を計算する。PatchCoreと同じ最近傍法だが、特徴抽出器がCNNではなく自己教師あり基盤モデルである点に注目する。ただし、結果の差を特徴抽出器だけに原因づけすぎない。

7.1 注意すべき差異

8. Pythonコード全体

"""AI体験学習用プログラム:画像からの異常検知

静的な被写体をウェブカメラで撮影し、正常状態として登録した後、
3方式の異常検知を連続実行して比較する。

実装方式
  1. PaDiM
     パッチ位置ごとの多変量ガウス分布とマハラノビス距離

  2. PatchCore
     正常パッチ特徴のメモリバンクと最近傍距離

  3. DINOv2特徴を用いた最近傍法
     DINOv2のパッチ特徴と正常参照パッチとのコサイン距離

注意:
  本プログラムは公式実装や論文ベンチマークの完全再現ではない。
  ウェブカメラを用いて、各方式の考え方と反応の違いを観察するための教材実装である。

必要ライブラリ:
  torch, torchvision, opencv-python, numpy, pillow
"""

import sys
import time
import tkinter as tk
from tkinter import messagebox

import cv2
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image, ImageTk
from torchvision import transforms
from torchvision.models import ResNet18_Weights, resnet18

DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if torch.cuda.is_available():
    torch.backends.cudnn.benchmark = True

IMG_SIZE = 256        # ResNet-18への入力画像の一辺
DINO_SIZE = 448       # DINOv2への入力画像の一辺。14の倍数であり、32x32パッチになる
N_CALIB = 5           # 基準値の較正用に追加撮影する正常画像の枚数
GRID = 32             # 異常マップの一辺のパッチ数
DISP = 256            # GUI表示画像の一辺
K_MAX = 9             # PatchCore 近傍数kの最大値
PADIM_DIM_MAX = 150   # 教材用の実用上限。ResNet-18の総特徴次元は448

PADIM_REG = 0.01              # PaDiMの共分散正則化
CORESET_PROJ_DIM = 64         # PatchCoreコアセット選択用のランダム射影次元
CORESET_CANDIDATE_LIMIT = 8192
DINO_NN_CHUNK = 10000         # DINOv2最近傍計算時の分割サイズ
NORMAL_CAPTURE_INTERVAL = 0.10  # 登録・較正時に同一フレームを連続取得しにくくする間隔

# ----------------------------------------------------------------------
# 特徴抽出器
# ----------------------------------------------------------------------
backbone = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1).to(DEVICE).eval()

feature_maps = {}

def save_feature(name):
    def hook(module, inputs, output):
        feature_maps[name] = output
    return hook

backbone.layer1.register_forward_hook(save_feature("layer1"))
backbone.layer2.register_forward_hook(save_feature("layer2"))
backbone.layer3.register_forward_hook(save_feature("layer3"))

dino = torch.hub.load("facebookresearch/dinov2", "dinov2_vits14", trust_repo=True)
dino = dino.to(DEVICE).eval()

preprocess = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225]),
])

def extract_features(frame_bgr):
    """1枚の画像から各方式用のパッチ特徴を抽出する"""
    if frame_bgr is None:
        raise ValueError("カメラ画像が取得できませんでした。")

    img = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)

    x = preprocess(cv2.resize(img, (IMG_SIZE, IMG_SIZE))).unsqueeze(0).to(DEVICE)
    x_dino = preprocess(cv2.resize(img, (DINO_SIZE, DINO_SIZE))).unsqueeze(0).to(DEVICE)

    with torch.inference_mode():
        feature_maps.clear()
        backbone(x)

        required = {"layer1", "layer2", "layer3"}
        if not required.issubset(feature_maps):
            raise RuntimeError("ResNetの中間特徴を取得できませんでした。")

        f1 = F.interpolate(feature_maps["layer1"], size=(GRID, GRID),
                           mode="bilinear", align_corners=False)
        f2 = feature_maps["layer2"]
        f3 = F.interpolate(feature_maps["layer3"], size=(GRID, GRID),
                           mode="bilinear", align_corners=False)

        # PaDiM用:layer1〜3を結合
        emb_padim = torch.cat([f1, f2, f3], dim=1)

        # PatchCore用:layer2〜3を結合し、局所近傍を平均
        emb_pc = F.avg_pool2d(torch.cat([f2, f3], dim=1),
                              kernel_size=3, stride=1, padding=1)

        # DINOv2用:パッチトークンを単位ノルム化
        tokens = dino.forward_features(x_dino)["x_norm_patchtokens"][0]
        emb_dino = F.normalize(tokens, dim=1).cpu().numpy().astype(np.float32, copy=False)

    def flatten(t):
        return (
            t[0]
            .permute(1, 2, 0)
            .reshape(GRID * GRID, -1)
            .cpu()
            .numpy()
            .astype(np.float32, copy=False)
        )

    emb_padim_np = flatten(emb_padim)
    emb_pc_np = flatten(emb_pc)

    feature_maps.clear()

    return emb_padim_np, emb_pc_np, emb_dino

# ----------------------------------------------------------------------
# 方式1:PaDiM
# ----------------------------------------------------------------------
class PaDiM:
    def fit(self, embeddings, n_dims):
        """正常特徴からパッチ位置ごとの多変量ガウス分布を推定する"""
        n_channels = embeddings.shape[2]
        n_dims = int(np.clip(n_dims, 1, n_channels))

        rng = np.random.RandomState(0)
        self.dim_idx = rng.choice(n_channels, n_dims, replace=False)

        x = embeddings[:, :, self.dim_idx].astype(np.float32, copy=False)
        self.mean = x.mean(axis=0).astype(np.float32)

        # 元コードの「cov + 0.01I の逆行列」と同じ計算を、
        # Woodburyの公式で N x N 側の逆行列に置き換える。
        # Nは正常画像枚数、dは特徴次元数。
        # dが大きい条件で 1024個の d x d 逆行列を作るより安定して軽い。
        centered = (x - self.mean[None, :, :]).astype(np.float32, copy=False)

        self.centered = np.ascontiguousarray(
            centered.transpose(1, 0, 2),
            dtype=np.float32
        )

        self.reg = np.float32(PADIM_REG)
        self.alpha = np.float32(1.0 / (x.shape[0] - 1))

        gram = np.einsum(
            "npd,mpd->pnm",
            centered,
            centered,
            optimize=True
        ).astype(np.float32, copy=False)

        eye = np.eye(x.shape[0], dtype=np.float32)[None, :, :]
        b = eye + (self.alpha / self.reg) * gram

        self.b_inv = np.linalg.inv(b.astype(np.float64)).astype(np.float32)

    def score_map(self, embedding):
        """マハラノビス距離による異常マップを返す"""
        diff = (
            embedding[:, self.dim_idx] - self.mean
        ).astype(np.float32, copy=False)

        v = np.einsum(
            "pnd,pd->pn",
            self.centered,
            diff,
            optimize=True
        )

        tmp = np.einsum(
            "pnm,pm->pn",
            self.b_inv,
            v,
            optimize=True
        )

        reg = float(self.reg)
        alpha = float(self.alpha)

        quad1 = (diff * diff).sum(axis=1) / reg
        quad2 = (alpha / (reg * reg)) * (v * tmp).sum(axis=1)

        dist2 = quad1 - quad2

        return np.sqrt(np.maximum(dist2, 0.0)).reshape(GRID, GRID)

# ----------------------------------------------------------------------
# 方式2:PatchCore
# ----------------------------------------------------------------------
class PatchCore:
    def fit(self, embeddings, sampling_ratio):
        """k-center貪欲法に基づくコアセット選択でメモリバンクを構築する"""
        self.memory_t = None
        self.memory_sq_t = None

        x = embeddings.reshape(-1, embeddings.shape[2]).astype(np.float32, copy=False)
        x = np.ascontiguousarray(x, dtype=np.float32)

        n_total = len(x)
        n_sample = max(1, int(round(n_total * sampling_ratio)))
        n_sample = min(n_sample, n_total)

        rng = np.random.RandomState(0)

        # 正常画像枚数とサンプリング率を大きくしたときに、
        # k-center選択が実用的な時間に収まらなくなることを避ける。
        # 先に候補集合を決定的乱数で縮約し、その候補上でk-centerを行う。
        n_candidates = min(n_total, max(n_sample, CORESET_CANDIDATE_LIMIT))

        if n_candidates < n_total:
            candidate_idx = rng.choice(n_total, n_candidates, replace=False)
            x_work = np.ascontiguousarray(x[candidate_idx], dtype=np.float32)
        else:
            candidate_idx = np.arange(n_total, dtype=np.int64)
            x_work = x

        if n_sample == n_candidates:
            selected_np = candidate_idx.copy()

        else:
            proj = rng.normal(
                loc=0.0,
                scale=1.0 / np.sqrt(float(CORESET_PROJ_DIM)),
                size=(x.shape[1], CORESET_PROJ_DIM)
            ).astype(np.float32)
            proj = np.ascontiguousarray(proj, dtype=np.float32)

            initial_index = int(rng.randint(n_candidates))

            # CUDAが使える場合は、コアセット選択の距離更新をGPUで行う
            if torch.cuda.is_available():
                x_t = torch.from_numpy(x_work).to(device=DEVICE, dtype=torch.float32)
                proj_t = torch.from_numpy(proj).to(device=DEVICE, dtype=torch.float32)

                z = x_t @ proj_t
                z_sq = (z * z).sum(dim=1)

                selected = torch.empty(n_sample, dtype=torch.long, device=DEVICE)
                selected[0] = initial_index

                min_dist2 = torch.full(
                    (n_candidates,),
                    float("inf"),
                    dtype=torch.float32,
                    device=DEVICE
                )

                for i in range(1, n_sample):
                    last = selected[i - 1]
                    d2 = z_sq + z_sq[last] - 2.0 * (z @ z[last])
                    torch.minimum(min_dist2, d2, out=min_dist2)

                    # 選択済み点を再選択しない。
                    # 重複特徴や同一フレームが多い条件で同じ点が繰り返し選ばれるのを防ぐ。
                    min_dist2[last] = -float("inf")

                    selected[i] = torch.argmax(min_dist2)

                selected_np = candidate_idx[selected.cpu().numpy()]

                del x_t, proj_t, z, z_sq, selected, min_dist2
                torch.cuda.empty_cache()

            else:
                z = (x_work @ proj).astype(np.float32)
                z_sq = (z ** 2).sum(axis=1)

                selected = np.empty(n_sample, dtype=np.int64)
                selected[0] = initial_index

                min_dist2 = np.full(n_candidates, np.inf, dtype=np.float32)

                for i in range(1, n_sample):
                    last = int(selected[i - 1])
                    d2 = z_sq + z_sq[last] - 2.0 * (z @ z[last])
                    np.minimum(min_dist2, d2, out=min_dist2)

                    # 選択済み点を再選択しない。
                    min_dist2[last] = -np.inf

                    selected[i] = int(min_dist2.argmax())

                selected_np = candidate_idx[selected]

        self.memory = x[selected_np].astype(np.float32, copy=False)
        self.memory = np.ascontiguousarray(self.memory, dtype=np.float32)
        self.memory_sq = (self.memory ** 2).sum(axis=1).astype(np.float32)

        if torch.cuda.is_available():
            self.memory_t = torch.from_numpy(self.memory).to(device=DEVICE, dtype=torch.float32)
            self.memory_sq_t = torch.from_numpy(self.memory_sq).to(device=DEVICE, dtype=torch.float32)

    def distances(self, embedding):
        """各パッチからメモリバンク各点までのユークリッド距離を返す"""
        q = embedding.astype(np.float32, copy=False)
        q = np.ascontiguousarray(q, dtype=np.float32)

        if self.memory_t is not None:
            q_t = torch.from_numpy(q).to(device=DEVICE, dtype=torch.float32)

            q2 = (q_t * q_t).sum(dim=1, keepdim=True)
            dist2 = q2 + self.memory_sq_t[None, :] - 2.0 * (q_t @ self.memory_t.T)

            dist = torch.sqrt(torch.clamp(dist2, min=0.0))

            return dist.cpu().numpy().astype(np.float32, copy=False)

        q2 = (q ** 2).sum(axis=1, keepdims=True)
        dist2 = q2 + self.memory_sq[None, :] - 2.0 * (q @ self.memory.T)

        return np.sqrt(np.maximum(dist2, 0.0)).astype(np.float32, copy=False)

    def score_map(self, embedding, k):
        """メモリバンクへのk近傍平均距離による異常マップを返す"""
        k = min(max(1, int(k)), len(self.memory))

        dist = self.distances(embedding)
        nearest = np.partition(dist, kth=k - 1, axis=1)[:, :k]

        return nearest.mean(axis=1).reshape(GRID, GRID)

    def fit_baselines(self, calib_embeddings):
        """較正用画像から k=1〜K_MAX の基準値をまとめて求める"""
        baseline_values = {k: 0.0 for k in range(1, K_MAX + 1)}

        for e in calib_embeddings:
            dist = self.distances(e)

            k_lim = min(K_MAX, dist.shape[1])
            part = np.partition(dist, kth=k_lim - 1, axis=1)[:, :k_lim]
            part.sort(axis=1)

            cumulative = np.cumsum(part, axis=1) / np.arange(
                1, k_lim + 1, dtype=np.float32
            )

            for k in range(1, K_MAX + 1):
                kk = min(k, k_lim)
                baseline_values[k] = max(
                    baseline_values[k],
                    float(cumulative[:, kk - 1].max())
                )

        self.baselines = {
            k: baseline_values[k] + 1e-6
            for k in range(1, K_MAX + 1)
        }

# ----------------------------------------------------------------------
# 方式3:DINOv2特徴を用いた最近傍法
# ----------------------------------------------------------------------
class DINOv2Nearest:
    def fit(self, embeddings):
        """参照正常画像の全パッチ特徴をメモリとして保持する"""
        self.memory_t = None

        self.memory = embeddings.reshape(-1, embeddings.shape[2]).astype(np.float32, copy=False)
        self.memory = np.ascontiguousarray(self.memory, dtype=np.float32)

        if torch.cuda.is_available():
            self.memory_t = torch.from_numpy(self.memory).to(device=DEVICE, dtype=torch.float32)

    def score_map(self, embedding):
        """コサイン距離の最近傍による異常マップを返す"""
        q = embedding.astype(np.float32, copy=False)
        q = np.ascontiguousarray(q, dtype=np.float32)

        if self.memory_t is not None:
            q_t = torch.from_numpy(q).to(device=DEVICE, dtype=torch.float32)
            best = torch.full(
                (q_t.shape[0],),
                -float("inf"),
                dtype=torch.float32,
                device=DEVICE
            )

            for start in range(0, self.memory_t.shape[0], DINO_NN_CHUNK):
                mem = self.memory_t[start:start + DINO_NN_CHUNK]
                sim = q_t @ mem.T
                best = torch.maximum(best, sim.max(dim=1).values)

            score = torch.clamp(1.0 - best, min=0.0)

            return score.cpu().numpy().astype(np.float32, copy=False).reshape(GRID, GRID)

        best = np.full(q.shape[0], -np.inf, dtype=np.float32)

        # メモリが大きい場合に備えて分割して最大類似度を求める
        for start in range(0, len(self.memory), DINO_NN_CHUNK):
            mem = self.memory[start:start + DINO_NN_CHUNK]
            sim = q @ mem.T
            best = np.maximum(best, sim.max(axis=1))

        score = np.clip(1.0 - best, 0.0, None)
        return score.reshape(GRID, GRID)

padim = PaDiM()
patchcore = PatchCore()
dino_nn = DINOv2Nearest()

# ----------------------------------------------------------------------
# カメラと画像表示
# ----------------------------------------------------------------------
def open_camera(index=0):
    """カメラを開く。WindowsではDirectShowを優先する"""
    if sys.platform.startswith("win"):
        c = cv2.VideoCapture(index, cv2.CAP_DSHOW)
        c.set(cv2.CAP_PROP_BUFFERSIZE, 1)

        if c.isOpened():
            return c

        c.release()

    c = cv2.VideoCapture(index)
    c.set(cv2.CAP_PROP_BUFFERSIZE, 1)

    return c

capture = open_camera(0)

if not capture.isOpened():
    raise RuntimeError("カメラを開けませんでした。カメラ番号や接続状態を確認してください。")

_last_normal_capture_time = 0.0

def read_frame():
    ret, frame = capture.read()
    if not ret or frame is None:
        raise RuntimeError("カメラ画像を取得できませんでした。")
    return frame

def read_normal_sample_frame():
    """登録・較正用の画像を、短時間に同一フレームを拾い続けにくい間隔で取得する"""
    global _last_normal_capture_time

    elapsed = time.perf_counter() - _last_normal_capture_time
    wait = NORMAL_CAPTURE_INTERVAL - elapsed

    if wait > 0:
        time.sleep(wait)

    frame = read_frame()
    _last_normal_capture_time = time.perf_counter()

    return frame

def show_image(panel, image_bgr):
    image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
    panel.photo = ImageTk.PhotoImage(Image.fromarray(image_rgb))
    panel.configure(image=panel.photo)

def overlay_heatmap(frame_bgr, score_map, limit):
    """異常マップをカラーマップ化して入力画像に重ねる"""
    base = cv2.resize(frame_bgr, (DISP, DISP))

    smap = cv2.resize(score_map.astype(np.float32), (DISP, DISP))
    smap = cv2.GaussianBlur(smap, (33, 33), 4)

    limit = max(float(limit), 1e-6)
    smap = np.clip(smap / limit, 0.0, 1.0)

    heat = cv2.applyColorMap((smap * 255).astype(np.uint8), cv2.COLORMAP_JET)

    return cv2.addWeighted(base, 0.5, heat, 0.5, 0)

# ----------------------------------------------------------------------
# GUIのイベント処理
# ----------------------------------------------------------------------
running = False

def register_normal():
    """正常画像を撮影して3方式を登録し、較正用画像で基準値を求める"""
    global running
    global _last_normal_capture_time

    running = False
    success = False
    _last_normal_capture_time = 0.0

    # 再登録時に前回のGPUメモリバンクを先に手放す。
    patchcore.memory_t = None
    patchcore.memory_sq_t = None
    dino_nn.memory_t = None
    if torch.cuda.is_available():
        torch.cuda.empty_cache()

    btn_register.configure(state=tk.DISABLED)
    btn_run.configure(
        text="処理開始",
        bg="orange",
        activebackground="dark orange",
        state=tk.DISABLED
    )

    result_padim.set("未実行")
    result_patchcore.set("未実行")
    result_dino.set("未実行")

    try:
        n = int(slider_normal.get())

        embs_padim = []
        embs_pc = []
        embs_dino = []

        for i in range(n):
            status_var.set(f"正常画像を登録中 {i + 1}/{n}")
            root.update_idletasks()

            frame = read_normal_sample_frame()
            e_padim, e_pc, e_dino = extract_features(frame)

            embs_padim.append(e_padim)
            embs_pc.append(e_pc)
            embs_dino.append(e_dino)

        status_var.set("正常状態をモデル化中(ガウス分布推定・コアセット選択・参照メモリ構築)")
        root.update_idletasks()

        padim.fit(np.stack(embs_padim), int(slider_dim.get()))
        patchcore.fit(np.stack(embs_pc), float(slider_ratio.get()) / 100.0)
        dino_nn.fit(np.stack(embs_dino))

        calib_padim = []
        calib_pc = []
        calib_dino = []

        for i in range(N_CALIB):
            status_var.set(f"基準値の較正用画像を撮影中 {i + 1}/{N_CALIB}")
            root.update_idletasks()

            frame = read_normal_sample_frame()
            e_padim, e_pc, e_dino = extract_features(frame)

            calib_padim.append(e_padim)
            calib_pc.append(e_pc)
            calib_dino.append(e_dino)

        padim.baseline = max(padim.score_map(e).max() for e in calib_padim) + 1e-6
        patchcore.fit_baselines(calib_pc)
        dino_nn.baseline = max(dino_nn.score_map(e).max() for e in calib_dino) + 1e-6

        success = True
        btn_run.configure(state=tk.NORMAL)

        status_var.set("正常画像の登録が完了しました。「処理開始」で連続検知を始められます。")

    except Exception as e:
        status_var.set(f"登録に失敗しました: {e}")
        messagebox.showerror("登録エラー", str(e))

    finally:
        btn_register.configure(state=tk.NORMAL)

        if not success:
            btn_run.configure(
                text="処理開始",
                bg="orange",
                activebackground="dark orange",
                state=tk.DISABLED
            )

def toggle_run():
    """連続処理の開始と停止を切り替える"""
    global running

    running = not running

    if running:
        btn_run.configure(
            text="処理停止",
            bg="tomato",
            activebackground="red"
        )
        status_var.set("連続処理中です。被写体の変化に対する3方式の反応を比較してください。")
    else:
        btn_run.configure(
            text="処理開始",
            bg="orange",
            activebackground="dark orange"
        )
        status_var.set("処理を停止しました。「処理開始」で再開できます。")

def detect(frame):
    """1フレームに対して3方式の異常検知を行い、結果を表示する"""
    emb_padim, emb_pc, emb_dino = extract_features(frame)

    k = int(slider_k.get())
    threshold = float(slider_th.get()) / 100.0

    results = (
        (
            panel_padim,
            result_padim,
            padim.score_map(emb_padim),
            padim.baseline
        ),
        (
            panel_patchcore,
            result_patchcore,
            patchcore.score_map(emb_pc, k),
            patchcore.baselines[k]
        ),
        (
            panel_dino,
            result_dino,
            dino_nn.score_map(emb_dino),
            dino_nn.baseline
        ),
    )

    for panel, result_var, smap, baseline in results:
        show_image(panel, overlay_heatmap(frame, smap, baseline * threshold))

        ratio = float(smap.max() / baseline)
        judge = "異常あり" if ratio > threshold else "正常"

        result_var.set(f"スコア比 {ratio * 100:.0f} %  判定: {judge}")

def loop():
    """ライブ映像の更新と、処理中であれば連続検知を行う主ループ"""
    global running

    try:
        frame = read_frame()
        show_image(panel_input, cv2.resize(frame, (DISP, DISP)))

        if running:
            detect(frame)

    except Exception as e:
        running = False
        btn_run.configure(
            text="処理開始",
            bg="orange",
            activebackground="dark orange"
        )
        status_var.set(f"処理エラー: {e}")

    root.after(1 if running else 100, loop)

def on_close():
    global running

    running = False

    try:
        capture.release()
    finally:
        root.destroy()

# ----------------------------------------------------------------------
# GUIの構築
# ----------------------------------------------------------------------
root = tk.Tk()
root.title("AI体験学習:画像からの異常検知(PaDiM / PatchCore / DINOv2特徴最近傍法)")
root.protocol("WM_DELETE_WINDOW", on_close)

frame_sliders = tk.Frame(root)
frame_sliders.pack(padx=10, pady=5)

slider_normal = tk.Scale(
    frame_sliders,
    from_=5,
    to=50,
    orient=tk.HORIZONTAL,
    length=230,
    label="正常画像の枚数"
)
slider_normal.set(20)

slider_dim = tk.Scale(
    frame_sliders,
    from_=10,
    to=PADIM_DIM_MAX,
    orient=tk.HORIZONTAL,
    length=230,
    label="PaDiM 使用特徴次元数"
)
slider_dim.set(100)

slider_ratio = tk.Scale(
    frame_sliders,
    from_=1,
    to=10,
    orient=tk.HORIZONTAL,
    length=230,
    label="PatchCore サンプリング率 [%]"
)
slider_ratio.set(10)

slider_k = tk.Scale(
    frame_sliders,
    from_=1,
    to=K_MAX,
    orient=tk.HORIZONTAL,
    length=230,
    label="PatchCore 近傍数 k"
)
slider_k.set(1)

slider_th = tk.Scale(
    frame_sliders,
    from_=100,
    to=300,
    orient=tk.HORIZONTAL,
    length=230,
    label="異常判定しきい値(スコア比) [%]"
)
slider_th.set(150)

slider_normal.grid(row=0, column=0, padx=5)
slider_dim.grid(row=0, column=1, padx=5)
slider_ratio.grid(row=0, column=2, padx=5)
slider_k.grid(row=1, column=0, padx=5)
slider_th.grid(row=1, column=1, padx=5)

frame_buttons = tk.Frame(root)
frame_buttons.pack(pady=5)

btn_register = tk.Button(
    frame_buttons,
    text="正常画像の登録",
    width=20,
    command=register_normal
)

btn_run = tk.Button(
    frame_buttons,
    text="処理開始",
    width=24,
    bg="orange",
    activebackground="dark orange",
    state=tk.DISABLED,
    command=toggle_run
)

btn_register.grid(row=0, column=0, padx=10)
btn_run.grid(row=0, column=1, padx=10)

frame_panels = tk.Frame(root)
frame_panels.pack(padx=10, pady=5)

placeholder = ImageTk.PhotoImage(Image.new("RGB", (DISP, DISP), (60, 60, 60)))

result_padim = tk.StringVar(value="未実行")
result_patchcore = tk.StringVar(value="未実行")
result_dino = tk.StringVar(value="未実行")

tk.Label(frame_panels, text="カメラ映像(ライブ)").grid(row=0, column=0)
tk.Label(frame_panels, text="PaDiM").grid(row=0, column=1)
tk.Label(frame_panels, text="PatchCore").grid(row=0, column=2)
tk.Label(frame_panels, text="DINOv2特徴最近傍法").grid(row=0, column=3)

panel_input = tk.Label(frame_panels, image=placeholder)
panel_padim = tk.Label(frame_panels, image=placeholder)
panel_patchcore = tk.Label(frame_panels, image=placeholder)
panel_dino = tk.Label(frame_panels, image=placeholder)

panel_input.grid(row=1, column=0, padx=5)
panel_padim.grid(row=1, column=1, padx=5)
panel_patchcore.grid(row=1, column=2, padx=5)
panel_dino.grid(row=1, column=3, padx=5)

tk.Label(frame_panels, textvariable=result_padim).grid(row=2, column=1)
tk.Label(frame_panels, textvariable=result_patchcore).grid(row=2, column=2)
tk.Label(frame_panels, textvariable=result_dino).grid(row=2, column=3)

tk.Label(
    frame_panels,
    text="異常マップの色: 青=異常度低、赤=しきい値到達(基準値としきい値で正規化)"
).grid(row=3, column=0, columnspan=4, pady=3)

status_var = tk.StringVar(
    value="ライブ映像で被写体を確認し、最初に「正常画像の登録」を押してください。"
)

tk.Label(
    root,
    textvariable=status_var,
    anchor="w"
).pack(fill="x", padx=10, pady=5)

loop()
root.mainloop()

if capture.isOpened():
    capture.release()

9. 実験のヒント

10. 実験の進め方

10.1 実験1:何も変えない場合

正常画像を登録した後、被写体に何も加えずに「処理開始」する。3方式のスコア比と異常マップを記録する。

この実験では、完全に同じ状態でもカメラノイズ、照明の揺れ、ピントの変化などにより、スコア比が100%を超える場合があることを確認できる。

10.2 実験2:異物を置く

正常状態を登録した後、画面内に小さな異物を置く。異物の場所に赤い領域が出るか、スコア比がどう変わるかを観察する。

異物の大きさや色を変えると、反応の強さが変化する場合がある。ただし、赤い領域の境界は画素単位で正確な輪郭ではなく、32×32のパッチ単位の異常度を表示したものである。

10.3 実験3:被写体を少し動かす

異物を加えず、被写体やカメラ位置を少しだけ動かす。PaDiMはパッチ位置ごとに正常分布を持つため、位置ずれに反応しやすい場合がある。PatchCoreとDINOv2特徴最近傍法は、位置を固定せず近い特徴を探すため、PaDiMとは異なる反応を示す場合がある。

ただし、この結果だけから「どの方式が常に位置ずれに強い」とは結論しない。被写体、背景、照明、特徴抽出器、入力解像度の影響を受けるためである。

10.4 実験4:照明だけを変える

被写体を動かさず、照明の明るさや向きを少し変える。異物や傷がなくても、見た目が変われば異常として反応する場合がある。

この実験により、本教材の異常検知が「欠陥そのもの」を理解しているのではなく、「登録状態からの画像特徴のずれ」を見ていることを確認できる。

10.5 実験5:PatchCoreの近傍数kを変える

連続処理中にPatchCoreの近傍数kを1から9へ変える。同じ登録、同じ被写体、同じ異常に対して、PatchCoreの異常マップとスコア比がどう変化するかを観察する。

kを大きくすると、1つの最近傍だけではなく複数の近傍距離の平均を用いる。そのため、小さなノイズへの反応が弱くなる場合がある一方、小さな異常のスコアも下がる場合がある。

10.6 実験6:登録枚数を変える

正常画像の枚数を5枚、20枚、50枚などに変えて登録し直す。登録時間、スコア比、異常マップの安定性を観察する。

この実験では、正常画像を増やすと必ず精度が上がると決めつけない。正常画像の中に余計な変化が含まれると、それも正常として登録されるためである。

11. 記録表の例

実験条件 PaDiM スコア比・判定 PatchCore スコア比・判定 DINOv2特徴最近傍法 スコア比・判定 異常マップの観察 メモ
何も変えない 正常時の下限を確認
小さな異物を置く 異物の位置に反応したか
被写体を少し動かす 位置ずれへの反応
照明を変える 見た目の変化への反応

12. 考察ポイント

13. うまく動かないとき