DINOv2のアテンションマップを取得してみる

はじめに

株式会社ジャパン・インフラ・ウェイマークの川邉です。 当社はNTT西日本の子会社で、ドローン×画像解析AIを活用したインフラ点検を主に行っています。

2025年8月にMeta社が DINOv3 を発表しました。これは2023年に発表された DINOv2 の強化版という位置付けのもので、学習データ数、パラメータ数共に増加したうえで、4Kなどの高解像度画像に対する精度も上げたものになります。 当然、基本性能は2023年の DINOv2 よりも上ですが、以下のような理由により、 DINOv2 の方が使いやすい局面もあります。

  • 要求精度や画像サイズなどによっては DINOv2 で性能が足りる
  • DINOv2 は事前学習済みモデルを含めて Apache2.0ライセンス で提供されているので使いやすい(参考
  • DINOv3 は DINOv3ライセンス という特殊なライセンスで提供されている(参考

そこで本稿では執筆時点でのDINOv2の最新コードを使って、DINOv2 の特徴の一つであるアテンションマップを取得する処理について調査した結果をまとめていきます。

対象読者

本記事が想定する対象読者は以下の通りです。

  • Python のプログラムを書くことができる
  • AIによる画像処理を行っていて、背景のノイズ影響を抑えるなどの処理をしたい

事前知識

本稿はアテンションマップを取得する具体的な手法を主題としているため、DINOv2 や アテンションマップ といった技術要素については先行する諸記事をご参照いただくこととし、本稿ではざっくりと説明するにとどめます。

DINOv2について

2023年にMeta社が発表した自己教師あり学習による画像処理の基盤モデル(Foundation Model)です。ざっくりいうと、特定の用途に特化したものではなく、画像認識全般に対して高い性能を有するモデルです。

特定の用途に特化していないので、例えば、物体検出で有名な Ultralytics社 の YOLO や、以前に記事を書いた Roboflow 社の RD-DETR のように、インストールしてコマンドを実行したら所定の物体を検出してくれる・・・というような使い方はできません。

AIを実行して得られるのは特徴量と呼ばれる画像全体や、画像の細部の特徴を表現するためのデータで、利用者は目的に応じてこの特徴データを活用していきます。比較的シンプルな用途としては、画像の特徴同士を比較して、似ている画像を探すという画像検索的なものが考えられます。

その他、DINOv2を活用したプロダクトとして有名な例としては以下のようなものがあります。

  • Depth Anything (公式リポジトリ): 1枚の写真から奥行き情報を推定する単眼深度推定モデルです
  • Segment Anything Model v2(公式リポジトリ): 画像の指定した領域周辺をいい感じに(としか言いようがない)セグメンテーションするモデルです
  • AnomalyDINO(公式リポジトリ): DINOv2 で得られる特徴量を利用して、入力された画像の異常を検知する異常検知のモデルです

なお、DINOv2が発表された当時はCC BY-NC 4.0という商用利用不可のライセンスだったため、発表当時に書かれた記事の中では商用利用不可として紹介されています。しかし、2023年8月にコード・モデル共にApache2.0ライセンスに変更されています(参考)。

アテンションマップ

これまたざっくりいうとどこの領域の特徴を特に重視するかを示す情報です。以下の具体例を見た方が早いと思います。

元画像
作成されたアテンションマップ

「元画像」はPexelsからダウンロードしたものです。LearnOpenCV のDINOv2 の解説記事でも同じ画像が利用されているので、本稿で説明するプログラムの精度評価のために同じものを利用しました。

「作成されたアテンションマップ」は、後で説明するプログラムにて生成したものです。DINOv2のアテンションマップは Block0 ~ Block11 の 12 層が生成されますが、層が深くなるほどアテンションの精度が上がっていき、最終層(Block11)では背景の重みはほぼ 0 となり、犬と、犬の顔付近の重みが強くなっている事が分かります。

このように、入力された画像の特徴を適切に評価するために、重視すべき領域とすべきでない領域を適切に重みづけするための利用されるのがアテンションマップです。

アテンションマップの出力コード

上で例として挙げたアテンションマップを出力するために作成した Python のコードを通して、DINOv2 のアテンションマップの取得方法について説明していきたいと思います。

なお、アテンションマップはあくまで最終アウトプットである特徴量の作成に利用されているものなので、DINOv2 の標準的な機能で取得することはできません。 そこで、本稿では DINOv2 内で実施されているアテンションマップの計算手順を再現する事で、アテンションマップの取得を実現しています。そのため、以下のコードは執筆時点での最新版の DINOv2(コミットハッシュ: b194f00db6136677fc8a4cc2ef2168f7699dfba2) で動くことを前提に作成されています。

テストコードの構成

dinov2_attention_map_project/
  ├── dinov2_attention_map/
  │   ├── main.py
  │   ├── __init__.py
  └── pyproject.toml

main.py

import torch
import torchvision.transforms as T
import matplotlib.pyplot as plt
from PIL import Image


def main():
    # 画像ファイルは以下のものを利用
    # https://images.pexels.com/photos/1108099/pexels-photo-1108099.jpeg
    IMAGE_PATH = "pexels-photo-1108099.jpeg"

    # 保存するファイル名
    OUTPUT_PATH = "all_layers_attention.png"

    # デバイス設定
    device = "cuda" if torch.cuda.is_available() else "cpu"

    # モデルロード
    model_name = "dinov2_vits14"

    # DINOv2の内部の構造に依存する実装のため、コミットハッシュまで指定
    commit_hash = "b194f00db6136677fc8a4cc2ef2168f7699dfba2"
    model = torch.hub.load(f"facebookresearch/dinov2:{commit_hash}", model_name).to(
        device
    )
    model.eval()

    # モデルからパッチサイズを取得(dinov2_vits14 の場合は 14)
    patch_size = model.patch_embed.patch_size[0]

    num_patches_side = 32
    input_size = num_patches_side * patch_size
    total_patches = num_patches_side**2

    # 画像の準備
    img_raw = Image.open(IMAGE_PATH).convert("RGB")
    transform = T.Compose(
        [
            T.Resize((input_size, input_size)),
            T.ToTensor(),
            T.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
        ]
    )
    img_tensor = transform(img_raw).unsqueeze(0).to(device)

    # 全ブロックのアテンションを抽出
    all_layer_attentions = []

    # ここ以降の処理がDINOv2の内部のコードにかなり依存しているため、別のコミットハッシュの場合は動かない可能性がある
    with torch.no_grad():
        feat = model.prepare_tokens_with_masks(img_tensor)

        for blk in model.blocks:
            x = blk.norm1(feat)
            B, N, C = x.shape

            # QKV計算
            qkv = blk.attn.qkv(x).reshape(
                B, N, 3, blk.attn.num_heads, C // blk.attn.num_heads
            )
            q, k, v = torch.unbind(qkv, 2)
            q, k, v = [t.transpose(1, 2) for t in [q, k, v]]

            # アテンションスコア算出
            attn = (q @ k.transpose(-2, -1)) * blk.attn.scale

            # 正規化処理
            attn = attn.softmax(dim=-1)

            # [CLS]から画像パッチへのアテンションを平均して保持
            avg_attn = (
                attn[0, :, 0, -total_patches:]
                .mean(dim=0)
                .reshape(num_patches_side, num_patches_side)
            )
            all_layer_attentions.append(avg_attn.cpu().numpy())

            feat = blk(feat)

    # 以降は表示用の処理
    num_layers = len(all_layer_attentions)
    cols = 4
    rows = (num_layers + cols - 1) // cols

    fig, axes = plt.subplots(rows, cols, figsize=(15, rows * 3.5))
    fig.suptitle(f"Attention Maps ({model_name})", fontsize=16)

    for i, attn_map in enumerate(all_layer_attentions):
        r, c = divmod(i, cols)
        ax = axes[r, c] if rows > 1 else axes[c]

        # 0-1正規化
        attn_map = (attn_map - attn_map.min()) / (
            attn_map.max() - attn_map.min() + 1e-8
        )

        ax.imshow(img_raw.resize((input_size, input_size)), alpha=0.3)
        ax.imshow(
            attn_map,
            cmap="magma",
            alpha=0.7,
            extent=(0, input_size, input_size, 0),
            interpolation="bilinear",
        )
        ax.set_title(f"Block {i}")
        ax.axis("off")

    # 余ったサブプロットを消す
    for j in range(i + 1, rows * cols):
        r, c = divmod(j, cols)
        ax = axes[r, c] if rows > 1 else axes[c]
        ax.axis("off")

    plt.tight_layout()
    plt.subplots_adjust(top=0.92)
    plt.savefig(OUTPUT_PATH, dpi=300)
    print(f"アテンションマップを保存しました: {OUTPUT_PATH}")
    plt.show()


if __name__ == "__main__":
    main()

pyproject.toml

[tool.poetry]
name = "dinov2_attention_map"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

[[tool.poetry.source]]
name = "pytorch_cu118"
url = "https://download.pytorch.org/whl/cu118"
priority = "explicit"

[tool.poetry.dependencies]
python = ">=3.10,<3.12"
torch = {version = "2.0.1+cu118", source = "pytorch_cu118"}
torchvision = {version = "0.15.2+cu118", source = "pytorch_cu118"}
matplotlib = "^3.10.8"
numpy = "<2.0.0"

[tool.poetry.scripts]
dinov2-attn-map = "dinov2_attention_map.main:main"

[build-system]
requires = ["poetry-core>=1.5.0"]
build-backend = "poetry.core.masonry.api"

ポイント

アテンションマップの取得は上記コードの

with torch.no_grad():
    feat = model.prepare_tokens_with_masks(img_tensor)

    for blk in model.blocks:
        x = blk.norm1(feat)
        B, N, C = x.shape

        # QKV計算
        qkv = blk.attn.qkv(x).reshape(
            B, N, 3, blk.attn.num_heads, C // blk.attn.num_heads
        )
        q, k, v = torch.unbind(qkv, 2)
        q, k, v = [t.transpose(1, 2) for t in [q, k, v]]

        # アテンションスコア算出
        attn = (q @ k.transpose(-2, -1)) * blk.attn.scale

        # 正規化処理
        attn = attn.softmax(dim=-1)

        # [CLS]から画像パッチへのアテンションを平均して保持
        avg_attn = (
            attn[0, :, 0, -total_patches:]
            .mean(dim=0)
            .reshape(num_patches_side, num_patches_side)
        )
        all_layer_attentions.append(avg_attn.cpu().numpy())

        feat = blk(feat)

の部分で実施されています。そこで、上記のコードの各処理について順を追って説明していきたいと思います。

なお、上記の部分より前は画像の読み込み、後は結果画像の出力を実施しているだけですので、本稿では説明を割愛します。

基本的な流れについて

本プログラムでは、DINOv2 のコードの中で特徴量の計算を行っている処理を追って、その中でアテンションマップを作成するのに必要な手順部分のみをピックアップ・再現した物になります。

上のコードでは

feat = model.prepare_tokens_with_masks(img_tensor)

の部分がクラストークン(STEP1)の取得処理。

for blk in model.blocks:

のループの中で実施されているのが、アテンションマップの計算処理(STEP2)となります。

STEP1: クラストークンの取得

DINOv2 の特徴量を算出しているコードでは、以下のように prepare_tokens_with_masksという関数をコールしてクラストークンを取得したのち、Blockクラスのコンストラクタに渡しています(公式ブランチ)。

dinov2/models/vision_transformer.py

def forward_features(self, x, masks=None):
    if isinstance(x, list):
        return self.forward_features_list(x, masks)

    x = self.prepare_tokens_with_masks(x, masks)

    for blk in self.blocks:
        x = blk(x)

そこで本プログラムでも、同様に prepare_tokens_with_masks をコールして必要なデータを取得しました。

なお、prepare_tokens_with_masksの内容は以下の通りです(公式ブランチ

dinov2/models/vision_transformer.py

def prepare_tokens_with_masks(self, x, masks=None):
    B, nc, w, h = x.shape
    x = self.patch_embed(x)
    if masks is not None:
        x = torch.where(masks.unsqueeze(-1), self.mask_token.to(x.dtype).unsqueeze(0), x)

    x = torch.cat((self.cls_token.expand(x.shape[0], -1, -1), x), dim=1)
    x = x + self.interpolate_pos_encoding(x, w, h)

    if self.register_tokens is not None:
        x = torch.cat(
            (
                x[:, :1],
                self.register_tokens.expand(x.shape[0], -1, -1),
                x[:, 1:],
            ),
            dim=1,
        )

    return x
  1. x = self.patch_embed(x) で画像をパッチに変換
  2. x = torch.cat((self.cls_token.expand(x.shape[0], -1, -1), x), dim=1) でクラストークンを結合
  3. x = x + self.interpolate_pos_encoding(x, w, h) で位置情報を追加
  4. if self.register_tokens is not None: 以下の部分でレジスタトークンを挿入

という順に処理が実施されていますので、この関数の返り値の中には

  • クラストークン
  • レジスタトークン
  • 位置情報

の順で情報が格納されています。

STEP2: アテンションマップの計算

Blockクラスのコンストラクタに渡されたクラストークンは、以下のように正規化後にAttentionクラスに渡されています(公式ブランチ)。

dinov2/layers/block.py

class Block(nn.Module):
    def __init__(
        self,
        # 中略
        norm_layer: Callable[..., nn.Module] = nn.LayerNorm,
        attn_class: Callable[..., nn.Module] = Attention,
        ffn_layer: Callable[..., nn.Module] = Mlp,
    ) -> None:
        super().__init__()
        # print(f"biases: qkv: {qkv_bias}, proj: {proj_bias}, ffn: {ffn_bias}")
        self.norm1 = norm_layer(dim)
        self.attn = attn_class(
            dim,
            num_heads=num_heads,
            qkv_bias=qkv_bias,
            proj_bias=proj_bias,
            attn_drop=attn_drop,
            proj_drop=drop,
        )

そこで、今回のプログラムでも、STEP1 で取得したクラストークンを正規化(x = blk.norm1(feat))したのちに、Attentionクラスのforward関数(公式ブランチ)と同様の手順でアテンションマップを計算しています。

オリジナルのコードは以下のようになっていますので、blk.attn.qkv(x).reshape 以降3行分についてはオリジナルの処理をそのまま利用しています。

dinov2/layers/attention.py

def forward(self, x: Tensor, is_causal: bool = False) -> Tensor:
    B, N, C = x.shape
    qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, C // self.num_heads)
    q, k, v = torch.unbind(qkv, 2)
    q, k, v = [t.transpose(1, 2) for t in [q, k, v]]
    x = nn.functional.scaled_dot_product_attention(
        q, k, v, attn_mask=None, dropout_p=self.attn_drop if self.training else 0, is_causal=is_causal
    )
    x = x.transpose(1, 2).contiguous().view(B, N, C)
    x = self.proj_drop(self.proj(x))

アテンションスコアの計算については、オリジナルコードは nn.functional.scaled_dot_product_attention の中で実施していますが、この関数をそのまま利用してしまうと肝心のアテンションの値が取得できませんので、アテンションの計算部分のみ以下のように実施しています。

attn = (q @ k.transpose(-2, -1)) * blk.attn.scale
attn = attn.softmax(dim=-1)

なお、この計算式はAttention is all you need (2017)という論文に記載されたもので、オリジナルの論文では以下のような計算式で示されています。

 \displaystyle \mathrm{Attention}(Q, K, V) = \mathrm{softmax}\left( \frac{QK^T}{\sqrt{d_k}} \right) V

ここまで完了した時点で attn の内容は [1, num_heads, total_tokens, total_tokens] の順でデータが並んだ状態になっています。さらに、total_tokens

  1. [CLS]トークン: 1個
  2. Registerトークン: 4個
  3. Patchトークン: 画面を14x14のパッチで分割した個数(=total_patches

の順で要素が並んでいるため、attn の末尾から total_patchs 分を取り出す(attn[0, :, 0, -total_patches:])ことで Patch トークンのリストを取得する事ができます。

さらに、Patch トークンのリストには画像全体を 14pixel × 14pixel のパッチで分割した際の、各パッチ領域のアテンションの値(重要度)が入っていますので、これをnum_patches_side×num_patches_sideのサイズにリシェイプする事で、最終的にアテンションマップが完成します。

まとめ

本稿では DINOv2 のアテンションマップ取得手順について、オリジナルのソースコードを交えつつまとめてみました。 画像認識系のAIを開発していると、本来注目すべきではない箇所に対する誤検知に苦労する事もありますので、アテンションマップの情報を活用する事で背景などに対する誤検知の抑制に活用できるのではないかと思います。

執筆者

川邉 隆伸 (ジャパン・インフラ・ウェイマーク開発部所属)

画像認識系AIの開発や、それらを提供するSaaS環境の構築を行っています。

免責事項

本記事に記載された情報は、2026年1月時点での公開情報および筆者の検証・調査結果に基づくものです。

  • 記事に記載されている各プログラムやモジュールの機能、実行条件などは予告なく変更される場合があります
  • 本記事の内容を実践される際は、必ず各プログラムの最新の公式ドキュメントをご確認ください
  • 本記事の情報に基づいて行われた意思決定や実装により生じた損害について、筆者および所属組織は一切の責任を負いかねます

参考資料・出典

本記事の執筆にあたり参考としたページは以下の通りです

商標

  • Python は Python Software Foundation の登録商標または商標です
  • Ultralytics YOLO は Ultralytics の登録商標または商標です
  • Pexels は Pexels GmbH の登録商標または商標です

© NTT WEST, Inc.