VLMはBounding Boxを理解できるよって話

この記事は[Qiita LLM・LLM活用 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/large-language-model)の7日目の記事です。
ここではQwen2-VLを例に、画像に対するBounding Boxを入力や出力に含む対話生成の例を見ていきます。
なおこの記事中における「理解」は、バウンディングボックスの概念が伝わるのみならず、**テキストで表現されたバウンディングボックスの座標が画像中の正確な座標と結びつく**ことを意図しています。

# VLMがBounding Boxを理解できる理由
一部のVLMは**訓練時に明示的にBounding Boxが含まれたテキストでの学習**を行っています。
Bounding Boxを明示的に学習しているVLMの例としては、[LLaVA 1.5](https://arxiv.org/abs/2310.03744)や[Qwen-VL](https://arxiv.org/abs/2308.12966) ([Qwen2-VL](https://arxiv.org/abs/2409.12191)含む)、[Fuyu-8B](https://www.adept.ai/blog/fuyu-8b)が挙げられます。日本語特化モデルならLLaVA 1.5の学習に倣っている[llava-calm2-siglip](https://huggingface.co/cyberagent/llava-calm2-siglip)がBounding Boxの学習を行っています。

モデルがBounding Boxの座標をきちんと理解するにはその座標と画像中の位置のハードな結びつきの理解が必須ですので、逆に明示的に学習していないVLMにはBounding Boxを与えたところで、その概念の理解はできたとて座標としては微妙にずれた応答をしても仕方ありません。

# 具体的なテキスト形式｜Qwen2-VLの場合
具体的にどういうタスクを行っているか、そのプロンプト・出力の形式がどうなっているかは各モデルの論文中などに記載があります。
例えばQwen2-VLでのBounding Boxの入力形式は、画像の縦横サイズをそれぞれ1000として正規化した上で、`(left,top),(right,bottom)`の形のテキストで表現します。そしてモデルがその数値をBounding Boxであると捉えやすくなるように専用のspecial token `
`と`
`で修飾します。さらにBounding Boxに結びつくオブジェクトのテキストも`
`と`
`で修飾します。special tokenはそれぞれ1トークンずつですが、座標部分は普通のsubwordで表現されます。
結果として次の形になります。
```text
... 
{{OBJECT_NAME}}

({{left}},{{top}}),({{right}},{{bottom}})
 ...
```

タスクとしてはBounding Boxに基づく画像説明だったり、OCRだったりを学習させているようです。

論文中の具体例1。学習中に青い部分を正しく生成するよう学習させています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/88778bf5-6fe7-2e7c-f7fb-1e7f8a928a29.png)
> 引用元: [Qwen2-VL: Enhancing Vision-Language Model's Perception of the World at Any Resolution](https://arxiv.org/abs/2409.12191)

逆にBounding Boxを聞きたいオブジェクトはシングルクォートで囲むのがマナーのようです。

論文中の具体例2。モデルとの対話の例。下側の画像はBounding Box図示のために見せられているだけでQwen2-VLの出力はテキストだけです。一応。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/9ffe1789-4539-fa23-e7a5-df1fe0106746.png)
> 引用元: [Qwen2-VL: Enhancing Vision-Language Model's Perception of the World at Any Resolution](https://arxiv.org/abs/2409.12191)

ちなみにQwen-VLとQwen2-VLではspecial tokenが違うので注意してください。

# 実際にやってみる
ここでは`Qwen/Qwen2-VL-7B-Instruct-GPTQ-Int4`を使っています。

## ケース1
画像右上のほうの`negative`というラベルに対するBounding Boxを当ててみます。
入力画像
![im.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/1777d812-a91c-1b79-2336-a5fcef782e9a.png)

狙うのはここです。
![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/aab482ec-7036-3dc0-3edb-614162a53104.png)


入力の準備:
```python
# processor = transformers.AutoProcessor.from_pretrained("Qwen/Qwen2-VL-7B-Instruct-GPTQ-Int4")
OBJECT = "negative written in KSample block"
messages = [
    {
        "role": "user",
        "content": [
            { "type": "image", "image": "file:///path/to/image.png" },
            {
                "type": "text",
                "text": f"Detect the bounding box of '{OBJECT}'."
            },
        ],
    },
]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
image_inputs, video_inputs = qwen_vl_utils.process_vision_info(messages)
inputs = processor(text=[text], images=image_inputs, videos=video_inputs, padding=True, return_tensors="pt").to("cuda")
```
入力テキスト全体
```text

system
You are a helpful assistant.


user



Detect the bounding box of 'negative written in KSample block'.


assistant

```
応答:
```text
The bounding box for the text "negative" in the KSampler block is:

- Top-left corner: (x: 650, y: 150)
- Bottom-right corner: (x: 670, y: 170)

```
・・・いきなり出鼻をくじかれました。まず出力が期待するフォーマットと違います。さらに図示してみると思いっきりずれてます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/9d80b70e-0259-475c-1313-c802b07112ad.png)

## ケース2
途中まで無理やり出力を固定して期待するフォーマットに合わせてみます。

```python
OBJECT = "negative written in KSample block"
messages = [
    {
        "role": "user",
        "content": [
            { "type": "image", "image": "file:///path/to/image.png" },
            {
                "type": "text",
                "text": f"Detect the bounding box of '{OBJECT}'."
            },
        ],
    },
]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
# ----------- ここが大事 -----------
text += f"
{OBJECT}

"
# ----------- /ここが大事 -----------
image_inputs, video_inputs = qwen_vl_utils.process_vision_info(messages)
inputs = processor(text=[text], images=image_inputs, videos=video_inputs, padding=True, return_tensors="pt").to("cuda")
```
入力テキスト全体
```text

system
You are a helpful assistant.


user



Detect the bounding box of 'negative written in KSample block'.


assistant

negative written in KSample block


```
応答
```
(655,171),(705,201)


```
図示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/11738886-a907-ad89-5e73-4afe007336ca.png)
今度はばっちりです。学習時と同じフォーマットに合わせるのが板。

## ケース3
Systemプロンプトでフォーマットを誘導してみます。出力の先頭指定は外します。
```python
OBJECT = "negative written in KSample block"
messages = [
    {
        "role": "system",
        "content": "You are a helpful assistant. When output a bounding box of the object in the image, the bounding box should start with 
 and end with 
."
    },
    {
        "role": "user",
        "content": [
            { "type": "image", "image": "file:///path/to/image.png" },
            {
                "type": "text",
                "text": f"Detect the bounding box of '{OBJECT}'."
            },
        ],
    },
]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
# text += f"
{OBJECT}

" # 今回は先頭指定なし
image_inputs, video_inputs = qwen_vl_utils.process_vision_info(messages)
inputs = processor(text=[text], images=image_inputs, videos=video_inputs, padding=True, return_tensors="pt").to("cuda")
```
入力テキスト全体
```text

system
You are a helpful assistant. When output a bounding box of the object in the image, the bounding box should start with 
 and end with 
.


user



Detect the bounding box of 'negative written in KSample block'.


assistant

```
応答
```text

negative written in KSample block

(655,175),(705,205)


```
図示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/880ad651-f95a-77cc-a22a-4ace70729794.png)


普通に期待するフォーマットそのものが出てくるようになりました。座標は先程のケースとはわずかに違うものの、これも正しいと言って良さそうな範疇です。

## ケース4
逆に入力でBounding Boxを与えます。狙うのは画像右上のseedのところの値です。

```python
messages = [
    {
        "role": "user",
        "content": [
            { "type": "image", "image": "file:///path/to/image.png" },
            {
                "type": "text",
                "text": "What is written in 
the bounding box

(819,228),(955,265)
?",
            },
        ],
    },
]
```

図示。入力で意図するBounding Boxを右上赤枠で示していますが、実際にQwen2-VLに与える画像は先程までと同様この赤枠が描かれていない画像を入力しています。
![im4_bbox.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/1b845692-6dd5-77e6-b338-c8fe45565eea.png)

応答
```text
The bounding box contains the text "156680208700286".
```

入力側で与えられたBounding Boxテキストの指し示す座標も理解できています。

## ケース5
マルチターンです。ケース3同様special tokenの誘導はsystemプロンプトで行い、先頭出力の固定化は行っていません。
モデルからの応答に続いて追加の質問を何度も繰り返しています。

入力画像
![im.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/09cf4325-3185-9916-fd20-3ea5efcd5056.png)

最初の質問の用意
```python
messages = [
    {
        "role": "system",
        "content": "You are a helpful assistant. When output a bounding box of the object in the image, the bounding box should start with 
 and end with 
."
    },
    {
        "role": "user",
        "content": [
            { "type": "image", "image": "file:///path/to/img.png" },
            {
                "type": "text",
                "text": "How many cards are there in the image?"
            },
        ],
    },
}
```
入力テキスト全体（マルチターン開始時）
```text

system
You are a helpful assistant. When output a bounding box of the object in the image, the bounding box should start with 
 and end with 
.


user



How many cards are there in the image?


assistant

```

各ターンの質問・応答結果です。直前の応答に続けて新しい質問をくっつけて応答生成を繰り返しています。質問側も当然`
`で毎回終わりますがこのテーブルではオミットしてます。
途中からずっと隣の座標を聞き続けているので同じ質問が続きます。

| 質問/応答 | テキスト |
| --- | ---
| 質問 | How many cards are there in the image? |
| 応答 | There are seven cards in the image.<\|im_end\|> |
| 質問 | Detect the boundary box of the right most card. |
| 応答 | <\|object_ref_start\|>the right most card<\|object_ref_end\|><\|box_start\|>(725,271),(951,775)<\|box_end\|><\|im_end\|> |
| 質問 | Detect the boundary box of the next card. |
| 応答 | <\|object_ref_start\|>the next card<\|object_ref_end\|><\|box_start\|>(635,241),(775,703)<\|box_end\|><\|im_end\|> |
| 質問 | Detect the boundary box of the next card. |
| 応答 | <\|object_ref_start\|>the next card<\|object_ref_end\|><\|box_start\|>(535,225),(655,693)<\|box_end\|><\|im_end\|> |
| 質問 | Detect the boundary box of the next card. |
| 応答 | <\|object_ref_start\|>the next card<\|object_ref_end\|><\|box_start\|>(425,225),(535,693)<\|box_end\|><\|im_end\|> |
| 質問 | Detect the boundary box of the next card. |
| 応答 | <\|object_ref_start\|>the next card<\|object_ref_end\|><\|box_start\|>(297,241),(425,707)<\|box_end\|><\|im_end\|> |
| 質問 | Detect the boundary box of the next card. |
| 応答 | <\|object_ref_start\|>the next card<\|object_ref_end\|><\|box_start\|>(165,241),(297,747)<\|box_end\|><\|im_end\|> |
| 質問 | Detect the boundary box of the next card. |
| 応答 | <\|object_ref_start\|>the next card<\|object_ref_end\|><\|box_start\|>(31,271),(165,779)<\|box_end\|><\|im_end\|> |

図示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058126/bcf43b2d-c0f4-49f9-bc12-a354c61d2a2b.png)


実のところ入力画像の暗くなってる領域には下部中央の1枚やら対戦相手側の手札やらデッキやらがあるので正確には7枚ではない・・・のですがそれは置いておいてかなりうまくいっています。
VLMはBounding Boxを理解している！

# Limitation
残念ながらまだ完璧な精度というわけではなく、やってみた例を見ても分かるかと思いますが入力依存の傾向がかなりあります。
ここではプロンプトの違いの例だけしか出していませんが、他にも試してみた感じ入力画像のサイズにもかなり影響されます。サイズが大きすぎても小さすぎても失敗に繋がりやすくなり、いい感じにリサイズするとうまく行くというケースにいくつか遭遇しました。これはBounding Boxを用いる場合に限らず、他の種の質問応答でも同じような傾向です。

また、一つの応答内に複数のBounding Boxを出力させようとしても失敗しがちになりました。上の例でマルチターンで応答させているのはそういう事情もあります。無印Qwen-VLの論文を見る感じ学習データには複数Bounding BoxのケースもあるようですしQwen2-VLでも同様の学習は行われていると思いますが、もしかしたら複数出現より単独出現の事例数の方がずっと多いとかはあるのかもしれません。
このように正直まだ厄介な性質はあるものの、うまくコントロールすれば現状でもかなり面白い結果が得られそうでもあり、今後に期待大です。

それとこの記事では定量的な評価は一切できていません。VLMのオブジェクト認識の具体的な性能周りは自分はよく知らないというのが情けないものの正直なところです。実際のところBounding Boxが欲しいだけなら素直にOpen-Vocabulary Object Detectionモデルを使うほうがよさそうな感じはあるのでわざわざVLMを使うなら出力としてよりは入力としてBounding Boxを使ったり、あるいはすごく複雑な出力がほしいケースで使いたい気がしますが、画像処理ガチ勢の皆様方このあたりどうなんでしょうか・・・