画像からの異常検知(統計モデル(PaDiM)・メモリバンク(PatchCore)・基盤モデル特徴(DINOv2最近傍法)の比較実験ツール)
【概要】
画像からの異常検知の予備実験資料。統計モデル(PaDiM)、メモリバンク(PatchCore)、基盤モデル特徴(DINOv2特徴を用いた最近傍法)の3方式を、学習済み特徴抽出器と正常画像の登録のみで実装し、ウェブカメラ画像で連続比較する手順・コード・考察の視点をまとめる。
本教材の目的は、論文ベンチマークの再現ではなく、正常状態を登録してから、その状態からの見た目の変化を異常マップ(画像を格子状の小領域に分割し、領域ごとの異常度を並べたもの)として観察することである。
【目次】
1. 目的と前提
テーマは画像からの異常検知である。対象は、物体、床面、机上、壁面などの静的な被写体とする。人間や動体そのものを検出することは目的としないため、動きの検出に依存しない外観ベースの異常検知手法を用いる。
ここでいう「異常」とは、「故障」「危険」「不良品」を直接意味するものではない。本教材では、正常画像として登録した状態から、画像上の見た目が変化した部分を異常として扱う。したがって、異物、傷、汚れだけでなく、照明の変化、カメラ位置の変化、影、反射、ピントの変化も異常として反応する場合がある。
運用上は、正常登録時と検知実行時のいずれにおいても、カメラと被写体をできるだけ静止させて使用する。人が写り込んだ場合、人を認識しているのではなく、登録時と異なる見た目の変化として反応する可能性がある。
1.1 この実験で確認できること
- 正常画像を登録した後、異物、汚れ、位置ずれ、照明変化などを加えると、異常マップとスコア比(正常状態から求めた基準値を100 %とする相対的な異常度。4.1節で述べる)がどのように変化するか。
- PaDiM、PatchCore、DINOv2特徴を用いた最近傍法(登録済みの特徴の中から最も似た特徴を探し、その距離で異常度を判断する方法)の反応の違い。
- PatchCoreの近傍数kを変えたときの、異常マップやスコア比の変化。
- 正常画像の枚数、PaDiMの使用特徴次元数、PatchCoreのサンプリング率を変えたときの、登録時間や検知結果の変化。
- 同じ被写体・同じ登録条件の中で、どのような変化に反応しやすいか。
1.2 この実験だけでは確認できないこと
- どの手法が一般に最も優れているか。
- MVTec AD(工業製品の表面欠陥を集めた、異常検知で広く使われる公開データセット)などの標準ベンチマークにおける正確な性能。
- 各論文の公式実装と同じ精度が出るかどうか。
- 実際の製造現場で使える品質保証性能があるかどうか。
- スコア比の数値だけを用いた、手法間の厳密な優劣比較。
本教材では正解ラベル付きデータセットを使わないため、AUROC、AP、PRO(いずれも正解ラベルを用いて検出性能を数値化する評価指標)などの評価指標は計算しない。異常マップの位置、色の変化、スコア比の増減を観察し、実験条件と対応づけて考察する。
2. 手法候補の一覧
静的な被写体の外観異常を検知できる画像異常検知手法について、代表的な以下の7件を候補とした。発表年や会議名は文献上の位置づけを示すための情報であり、本教材の実験では、実装対象の3方式のみを扱う。
| No. | 手法名 | 代表的な出典 | 分類 | 概要 |
|---|---|---|---|---|
| 1 | PaDiM | ICPR 2020/2021 | 統計モデル | パッチ(画像や特徴マップを格子状に分割した小領域)の位置ごとに正常特徴を多変量ガウス分布でモデル化し、マハラノビス距離(特徴の分散と相関を考慮して、平均からの離れ具合を測る距離)で異常度を算出する。 |
| 2 | PatchCore | CVPR 2022 | メモリバンク | 正常パッチ特徴をメモリバンク(正常時の特徴をそのまま保存しておく集合)化し、最近傍距離で異常度を算出する。コアセット選択(元の集合の広がりを保つように代表点を選び出す処理)によりメモリ量を削減する。 |
| 3 | EfficientAD | WACV 2024 | 蒸留+オートエンコーダ | 軽量な教師・生徒構成(学習済みモデルの出力を教師として別のモデルを学習させる蒸留の構成)とオートエンコーダ(入力を低次元に圧縮してから復元するネットワーク)を組み合わせ、高速な異常検知を目指す手法である。 |
| 4 | AnomalyCLIP | ICLR 2024 | 視覚言語モデル | 視覚言語モデル(画像と自然言語を共通の特徴空間で扱うモデル)CLIPを異常検知に適応させ、画像とテキスト表現を用いて異常を扱う手法である。 |
| 5 | AnomalyDINO | WACV 2025 | 基盤モデル特徴+最近傍 | DINOv2などの自己教師あり(正解ラベルを使わず、データ自身から学習の手がかりを作る学習方式)基盤モデル(大規模データで事前学習し、多様な課題に転用できるモデル)の特徴を用いて、正常参照画像とのパッチ特徴の違いから異常を検知する考え方を持つ。 |
| 6 | Dinomaly | CVPR 2025 | 再構成(Transformer) | DINOv2特徴の再構成に基づく異常検知手法である。 |
| 7 | INP-Former | CVPR 2025 | 画像内プロトタイプ | 画像内の特徴からプロトタイプ(その領域を代表する特徴ベクトル)を作り、それを用いて異常を検知する考え方を持つ手法である。 |
3. 手法の選定
実行でき、仕組みを比較しやすく、追加のネットワーク学習を必要としない方式として、PaDiM、PatchCore、DINOv2特徴を用いた最近傍法の3方式を実装対象とした。
| 基準 | PaDiM | PatchCore | DINOv2特徴を用いた最近傍法 |
|---|---|---|---|
| 基準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. プログラムの仕様
- 構成:単一のPythonファイルである。
- モデル取得:初回実行時に、ImageNet学習済みResNet-18(torchvision経由)とDINOv2 ViT-S/14(torch.hub経由)を自動ダウンロードする。
- 入力:ウェブカメラから取得した画像である。カメラは関数
open_camera(0)で開き、その内部でcv2.VideoCaptureを用いる(WindowsではDirectShowを優先する)。 - 出力:カメラのライブ映像と、PaDiM・PatchCore・DINOv2特徴最近傍法の3方式の異常マップを横並びで表示する。
- 表示内容:各方式のスコア比と判定(正常/異常あり)、およびヒートマップの色の意味(青=異常度低、赤=しきい値到達)を表示する。
- GUI:tkinterで構成し、主要パラメータをスライダーで調整できる。
- 実行トリガー:「正常画像の登録」を行った後、オレンジ色の「処理開始」ボタンで連続検知を開始する。処理中はボタンが赤色の「処理停止」に変わる。
- 連続処理中も、PatchCoreの近傍数kと異常判定しきい値は次のフレームから反映される。
- 正常画像の枚数、PaDiM使用特徴次元数、PatchCoreサンプリング率は登録時に使われる値である。変更を反映するには、正常画像の登録をやり直す必要がある。
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:インストーラーによるインストール
- Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
- ダウンロードしたインストーラーを実行する。
- 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから
pythonコマンドを実行できない。 - 「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++ 再頒布可能パッケージをインストールし、続いて以下のコンポーネントを追加している。
- VCTools:C++ デスクトップ開発ワークロード(
--includeRecommendedにより、MSVC コンパイラ、C++ AddressSanitizer、vcpkg、CMake ツール、Windows 11 SDK 等の推奨コンポーネントが含まれる) - MSBuildTools:MSBuild によるビルドツールのワークロード
- VC.CMake.Project:Windows 向け C++ CMake ツール
- VC.Llvm.Clang:Windows 向け C++ Clang コンパイラ
- VC.Llvm.ClangToolset:MSBuild から Clang を使用するための clang-cl ツールセット
- Windows11SDK.26100:Windows 11 SDK(ビルド 10.0.26100)
追加のコンポーネントが必要になった場合は 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 プログラムを解釈・実行するソフトウェア)を選択する必要がある.
- コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(
Ctrl+Shift+P) Python: Select Interpreterと入力する
- 表示される一覧から,使用する Python(例:
C:\Program Files\Python312\python.exe)を選択する.
Python プログラム実行手順
[Windows での Python プログラム実行手順を見るには、ここをクリック]
Windows での Python 実行手順(Visual Studio Codeを使用)
プログラムファイルの作成と保存
- 左サイドバーの「エクスプローラー」アイコン(
Ctrl+Shift+E)をクリックする
- 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する
続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する
- フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
- ファイル名(例:
aitask.py.ファイル名は何でも良い)を入力しEnterを押す.拡張子は.py(Python ファイルを示す拡張子)とする
- 実行したいコードを選択し,
Ctrl+Cでコピーする.VS Code のエディタ領域にCtrl+Vで貼り付ける Ctrl+Sで保存する
プログラムの実行
- エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
- VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(
print関数の出力等)が表示される
- tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
- VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する
必要なライブラリのインストール
PyTorchとtorchvisionは、上記のPyTorchインストール手順で導入済みであることを前提とする。そのうえで、以下のライブラリを追加でインストールする。
管理者権限でコマンドプロンプトを起動する
(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。
次のコマンドを実行し、関連ライブラリをインストールする。
pip install -U --no-user opencv-python numpy pillow
実行
python anomaly_detection_demo.py
操作手順
- 初回実行時には学習済みモデルの自動ダウンロードが行われるため、インターネット接続が必要である。
- 左端のライブ映像を見ながら、カメラを静的な被写体に向ける。
- カメラと被写体をできるだけ固定し、照明が大きく変わらない状態にする。
- 「正常画像の登録」ボタンを押し、正常状態を撮影・登録する。続けて基準値の較正用画像が5枚撮影される。この間は、カメラと被写体を動かさない。
- 登録が完了すると、オレンジ色の「処理開始」ボタンが押せるようになる。これを押すと連続検知が始まる。
- 連続検知の実行中に、異物を置く、物体を少し動かす、照明を変えるなどの変化を加え、3方式の異常マップとスコア比がどのように変化するかを観察する。
- 「処理停止」ボタンで処理を止め、最後の検知結果を見比べる。被写体やカメラ位置を変えたときは、あらためて「正常画像の登録」からやり直す。
6. 調整可能なパラメータ
| スライダー | 初期値(推奨) | 範囲 | 説明 |
|---|---|---|---|
| 正常画像の枚数 | 20 | 5〜50 | 正常状態として登録する画像の枚数。多いほど正常状態のばらつきを反映しやすいが、登録時間とメモリ使用量も増える。このほかに、基準値の較正用として5枚が追加撮影される。 |
| PaDiM 使用特徴次元数 | 100 | 10〜150 | ランダムに選ぶ特徴次元数。大きくすると情報量は増える可能性があるが、特徴次元数に比例して登録時と検知時の計算量が増える。正常画像の枚数が少ない場合、大きすぎる次元数では正則化(分散共分散に小さな値を加えて計算を安定させる処理)の影響が強くなる。 |
| PatchCore サンプリング率 | 10 % | 1〜10 % | 正常パッチ特徴のうち、メモリバンクに保持する割合。大きいほど正常パターンを多く保持できるが、登録時間と検知時の近傍探索コストが増える。 |
| PatchCore 近傍数 k | 1 | 1〜9 | 異常度算出に用いる近傍数。k=1では最も近い正常パッチとの距離を用いる。kを大きくすると複数の近傍距離の平均を用いるため、局所的な外れ値への反応が変化する。 |
| 異常判定しきい値 | 150 % | 100〜300 % | 較正用正常画像の最大スコアを100%とし、現在のスコア比がこの値を超えた場合に「異常あり」と判定する。異常マップの色もこのしきい値で正規化される。 |
DINOv2特徴を用いた最近傍法は、最近傍1点を用いる構成としているため、近傍数に関するスライダーは持たない。
7. 実装上の扱いと原論文との差異
本教材は、PaDiM、PatchCore、DINOv2特徴を用いた最近傍法の基本的な考え方を比較体験するための実装である。公式実装や論文実験の完全再現ではない。特に、ウェブカメラでリアルタイムに近い操作感を得るため、入力解像度、特徴マップの空間サイズ、特徴抽出器、画像レベルスコアの扱いを教材向けに調整している。
| 項目 | 本実装での扱い | 説明 |
|---|---|---|
| PaDiM | ResNet-18のlayer1〜3を使い、32×32のパッチ特徴にそろえた上で、パッチ位置ごとのガウス分布を推定する。 | 位置ごとに「正常な特徴の分布」を持ち、そこから離れた特徴を異常とみなす方式として理解する。 |
| PatchCore | ResNet-18のlayer2〜3を使い、局所平均プーリング(近傍のパッチ特徴を平均してまとめる処理)を行った後、正常パッチ特徴をコアセット選択してメモリバンク化する。 | 正常パッチを記憶し、最も近い正常パッチからの距離で異常度を測る方式として理解する。 |
| DINOv2特徴最近傍法 | DINOv2 ViT-S/14のパッチ特徴を正規化し、正常画像の全パッチ特徴とのコサイン類似度(2つのベクトルのなす角の余弦で測る類似度)から異常度を計算する。 | PatchCoreと同じ最近傍法だが、特徴抽出器がCNNではなく自己教師あり基盤モデルである点に注目する。ただし、結果の差を特徴抽出器だけに原因づけすぎない。 |
7.1 注意すべき差異
- PaDiMとPatchCoreでは、処理速度を優先してResNet-18を用いる。より大きなCNNを使うと結果が変わる可能性があるが、本教材では扱わない。
- PaDiMでは、比較しやすいように異常マップを32×32にそろえている。原論文や公式実装と空間解像度が異なる場合がある。
- PatchCoreの画像レベルスコアの再重み付けなど、論文実装の細部は省略している。本教材では、較正用正常画像に基づく相対スコア比で判定する。
- DINOv2特徴最近傍法は、AnomalyDINOの完全再現ではない。DINOv2特徴を用いたパッチ単位の最近傍比較を体験するための教材実装である。
- 本教材では、画面全体を検知対象とする。対象物領域だけを切り出す前景マスク(対象物が写っている領域だけを1とする2値画像)処理は行わない。
- 画像レベルの判定には異常マップの最大値を用いる。最大値は小さなノイズにも反応しやすいので、赤い点が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. 実験のヒント
- カメラを固定し、照明を一定に保つ。被写体、カメラ位置、照明を変えたら登録からやり直す。
- 登録中に手や袖が写り込むと、それも正常状態の一部として登録される。
- まず何も変えずに「処理開始」し、正常時のスコア比を記録する。
- 次に異物を置く、被写体を少し動かす、照明を変えるなど、1回に1つだけ条件を変えて観察する。
- 複数の条件を同時に変えると、どの変化に反応したのか分かりにくくなる。
- 登録をやり直すと基準値も変わる。異なる登録同士のスコア比をそのまま比較してはいけない。
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. 考察ポイント
- PaDiMは、パッチ位置ごとに正常特徴の分布を持つ。したがって、同じ物体でも位置が変わると異常として反応する場合がある。
- PatchCoreとDINOv2特徴最近傍法は、どちらも正常パッチとの最近傍比較を行う。ただし、使っている特徴抽出器、入力解像度、局所集約、メモリの作り方が異なるため、結果の差を1つの理由だけで説明しない。
- 照明だけを変えた場合にもスコアが上がるなら、この実験で検出しているものは「意味としての欠陥」ではなく、「登録状態からの見た目の変化」であると分かる。
- スコア比は手法ごとの基準値に対する相対値である。手法間で数値の大小だけを比較して、どちらが優秀かを結論しない。
- 異常マップの赤色は、登録時の基準と現在のしきい値に対して高い異常度を示す。赤色の領域が必ず実際の欠陥位置と完全一致するわけではない。
- このコードでは正解ラベルを使わないため、検出精度を数値指標で評価する実験ではない。観察実験として、条件を1つずつ変え、そのときの反応を記録する。
13. うまく動かないとき
- カメラが開けない場合は、他のアプリがカメラを使用していないか確認する。必要に応じて、コード中の
open_camera(0)の引数(カメラ番号)を1などに変更する。 - 初回実行時はモデルのダウンロードに時間がかかる。ネットワーク制限のある環境では失敗する場合がある。
- CPUのみの環境では、登録や連続処理が遅くなる場合がある。特にDINOv2特徴抽出と近傍探索は計算量が大きい。
- 正常なのに「異常あり」が頻繁に出る場合は、しきい値を上げる、照明を安定させる、カメラを固定する、登録をやり直すなどを試す。
- 異常を加えても反応が弱い場合は、異常が小さすぎる、背景と似ている、しきい値が高すぎる、正常登録時に似た状態が含まれていた、などの可能性がある。