Llama 3.2で画像分類してみる（画像から戦闘力を測定）

# はじめに
最近、MetaがLlama 3.2をリリースしました。これは画像とテキストの両方からテキストを生成できる強力な大規模言語モデル（LLM）です。画像とテキストを入力にできるということは、一般的な知識を使った画像認識モデルを簡単に作れるようになるということです。その推論性能を楽しく検証するために、ドラゴンボールシリーズの象徴的なデバイスであるスカウターを再現してみましょう。

ドラゴンボールでは、スカウターは視覚的に人の戦闘力を測定するデバイスです。例えば、フリーザの戦闘力は530,000で、平均的な人間は5です。

Llama 3.2にfew-shot 学習として提供し、画像の戦闘力を推定します。

### 追記: 2024/09/30,2024/10/05
[Llama can now see and run on your device - welcome Llama 3.2](https://huggingface.co/blog/llama32)の記事を読むと、
> ただし、モデルは単一の画像に対応する場合に最も効果的に機能するため、transformers実装では入力で提供された最後の画像のみに対応します。これにより、品質が維持され、メモリが節約されます。

とのこと。
なので以下の内容は、最後の画像の情報しか使われていない可能性が高い。
few-shotを機能させるには、複数の画像を一枚の画像にするとかの工夫が必要かも・・？
ということで、複数枚を別々に処理するパターンと、複数枚を1枚の画像にまとめたパターンで実験してみました。

# セットアップ

```bash
uv init scouter
uv add transformers requests torch torchvision torchaudio pillow accelerate bitsandbytes
```

# few shot サンプル
著作権があるので、今回は実際の漫画のシーンではなくいらすとや さんの画像を使ってfew shotをしてみます。

1. サンプル1: 戦闘力: 5
    ![image1](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEisQBhkfoss8nSml4HD1asLJJLOXRw6gE6kcLVnjt_RSC9Aa1lckUT_Q21I_gAZ2HMChhrGzQzryKjLGa2ScAQIZ9Lv4nW2YQBEb2Cw3ytU3lTcqf0kTgLrZ8fG7ZMklmUnQRiB3cYx10e1/s180-c/business_man_macho.png)

2. サンプル2: 戦闘力: 10
    ![image2](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEip4rhI9GLvB0DQIK1NVLhTUct3nVrUaRDaCV49YTLGYEoZTjoL_Qrs6BJEEw_sBvLeT7BBP3Cnmm6de5BRn1HTun_GkqD7zz_tIC9cnriMBDac3gwGawNaOhUO8i2s23hwSqth0-Wv3SXU/s180-c/bug_ari_hiari.png)

3. 戦闘力を予測したい画像: 戦闘力: ?
    ![image3](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgSszatjsjXHPBKzWoWm-qUctt0LqOFO_relakJsv6NSvm4m0Ma_XKvba95vQZWe3ACE_N0RhuSqzsd7bm8U0_zKAcuQ-nKce2OWwfpzPNoZ6SZ21ft853r0ZH0ene54dRpTWIe-vrM24oZ/s180-c/chounouryoku.png)

# スクリプト

### scouter.py

```python
import requests
import torch
from PIL import Image
from transformers import MllamaForConditionalGeneration, AutoProcessor, BitsAndBytesConfig

# 量子化モデルを利用
# LLAMA 3.2 COMMUNITY LICENSE AGREEMENT に同意してからご利用ください
model_id = "SeanScripts/Llama-3.2-11B-Vision-Instruct-nf4"

model = MllamaForConditionalGeneration.from_pretrained(
    model_id,
    use_safetensors=True,
    device_map="auto",
)

def generate_with_text(text):
    """
    LLama3.2は日本語が得意でないみたいなので、英語で推論させ、後で翻訳させる。
    翻訳のためにテキスト入力だけさせるための関数
    """
    messages = [
        {"role": "user", "content": [
            {"type": "text", "text": text}
        ]}
    ]
    processor = AutoProcessor.from_pretrained(model_id)
    input_text = processor.apply_chat_template(messages, add_generation_prompt=True)
    inputs = processor(images=None, text=input_text, return_tensors="pt").to(model.device)
    output = model.generate(**inputs, max_new_tokens=512)
    input_ids = inputs['input_ids']
    input_length = input_ids.shape[1]
    output_ids = output[0]
    generated_ids = output_ids[input_length:]
    return processor.decode(generated_ids, skip_special_tokens=True)

url1 = "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEisQBhkfoss8nSml4HD1asLJJLOXRw6gE6kcLVnjt_RSC9Aa1lckUT_Q21I_gAZ2HMChhrGzQzryKjLGa2ScAQIZ9Lv4nW2YQBEb2Cw3ytU3lTcqf0kTgLrZ8fG7ZMklmUnQRiB3cYx10e1/s180-c/business_man_macho.png"
url2 = "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEip4rhI9GLvB0DQIK1NVLhTUct3nVrUaRDaCV49YTLGYEoZTjoL_Qrs6BJEEw_sBvLeT7BBP3Cnmm6de5BRn1HTun_GkqD7zz_tIC9cnriMBDac3gwGawNaOhUO8i2s23hwSqth0-Wv3SXU/s180-c/bug_ari_hiari.png"
url3 = "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgSszatjsjXHPBKzWoWm-qUctt0LqOFO_relakJsv6NSvm4m0Ma_XKvba95vQZWe3ACE_N0RhuSqzsd7bm8U0_zKAcuQ-nKce2OWwfpzPNoZ6SZ21ft853r0ZH0ene54dRpTWIe-vrM24oZ/s400/chounouryoku.png"
messages = [ 
    {"role": "user", "content": [
        {"type": "text", "text": "The following images are character of Manga. and it's power score."},
        {"type": "image"},
        {"type": "text", "text": "score: 5"},
        {"type": "image"},
        {"type": "text", "text": "score: 10"},
        {"type": "text", "text": "Please estimate the power score from the following image and tell me the reason."},
        {"type": "image"},
    ]}  
]
processor = AutoProcessor.from_pretrained(model_id)
input_text = processor.apply_chat_template(messages, add_generation_prompt=True)
images = [ 
    Image.open(requests.get(url1, stream=True).raw),
    Image.open(requests.get(url2, stream=True).raw),
    Image.open(requests.get(url3, stream=True).raw),
]
inputs = processor(images=images, text=input_text, return_tensors="pt").to(model.device)
input_ids = inputs['input_ids']
input_length = input_ids.shape[1]
output = model.generate(**inputs, max_new_tokens=256)
output_ids = output[0]
generated_ids = output_ids[input_length:]

en_result = processor.decode(generated_ids, skip_special_tokens=True).replace('
', '') # 
というタグが含まれていると、後続の処理で画像の埋め込みが必要になってしまうため、念の為抜いている
ja_result = generate_with_text(f"Translate following text in Japanese\n\n```{en_result}```")
print(ja_result)
```

# 結果

```
> uv run scouter.py
このイメージは、決意の表情をしたキャラクターを描いています。黒いスパイクの髪を持ち、青いシャツを着ています。キャラクターの手は防御の姿勢で、ピンクの光が取り囲んでいます。この光は
  キャラクターのエネルギーまたはパワーを表すと考えられます。

キャラクターの自信の表情とピンクの光の存在により、キャラクターが大きなエネルギーやパワーを操ることができることが推測できます。光の色は、キャラクターの感情や意志に結びついており、
  ャラクターが自分の周囲を操作または制御する能力と関連付けられます。

これらの観察点に基づくと、キャラクターのパワースコアを5と推定することができます。次の点を考慮してください。

*   キャラクターの自信の表情と決意の表情は、強い自信と決意を示しています。
*   ピンクの光の存在は、キャラクターがエネルギーやパワーを操ることができることを示しています。
*   キャラクターの防御の姿勢と手の挙げ方は、キャラクターが潜在的な脅威から自分や他人を守る準備ができていることを示しています。

全体的に、キャラクターの自信の表情、ピンクの光、防御の姿勢は、パワースコアを5と推定するのに役立ちます。
```

# 実行環境

```
> nvidia-smi                                                                                                                  general-processor: Sun Sep 29 01:03:19 2024

Sun Sep 29 01:03:19 2024
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 535.113.01             Driver Version: 535.113.01   CUDA Version: 12.2     |
|-----------------------------------------+----------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |         Memory-Usage | GPU-Util  Compute M. |
|                                         |                      |               MIG M. |
|=========================================+======================+======================|
|   0  NVIDIA GeForce RTX 2080        Off | 00000000:01:00.0 Off |                  N/A |
| 22%   49C    P2              74W / 215W |   7149MiB /  8192MiB |     22%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+
|   1  NVIDIA GeForce RTX 2080        Off | 00000000:02:00.0 Off |                  N/A |
| 22%   46C    P2             178W / 215W |   6605MiB /  8192MiB |     68%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+

+---------------------------------------------------------------------------------------+
| Processes:                                                                            |
|  GPU   GI   CI        PID   Type   Process name                            GPU Memory |
|        ID   ID                                                             Usage      |
|=======================================================================================|
|    0   N/A  N/A    170395      C   ...ma_vision/llama32/.venv/bin/python3     7146MiB |
|    1   N/A  N/A    170395      C   ...ma_vision/llama32/.venv/bin/python3     6602MiB |
+---------------------------------------------------------------------------------------+
```

- VRAM 8GB x 2
    - 推論ピーク時には、14GB/16GBぐらい使っていました。これぐらいだったらColaboratoryのT4 GPUのランタイムでも動きそうですね

# まとめ

Llama 3.2を使って、スカウターを簡単に実装できました。正解は誰にも分からないので、興味のある方はご自身の環境で試してみてください！この楽しい実験を通じて、最新のLLMがマルチモーダルな入力を処理し、コンテキストに応じた出力を生成する能力を実感できました。画像を複数入力できるというのが便利ですね。間違い探しなども今度は試してみたいなと思いました。

# 追記(2024/10/05) 複数の画像を1枚にまとめて、few shotとして処理できるかを検証する

## 1. 三つの画像をそれぞれ、独立して認識できるか？

### 準備

スクリプトで、三つの画像を縦に結合する。

```python
from PIL import Image                                                                                                                                                              
                                                                                                                                                                                   
def concatenate_images_vertically(image_paths, output_path):                                                                                                                       
    # 画像を開く                                                                                                                                                                   
    images = [Image.open(path) for path in image_paths]                                                                                                                            
                                                                                                                                                                                   
    # 幅を統一（ここでは最大幅に合わせる）                                                                                                                                         
    max_width = max(image.width for image in images)                                                                                                                               
                                                                                                                                                                                   
    # 高さの合計を計算                                                                                                                                                             
    total_height = sum(image.height for image in images)                                                                                                                           
                                                                                                                                                                                   
    # 新しい画像を作成（白背景）                                                                                                                                                   
    new_image = Image.new('RGB', (max_width, total_height), (255, 255, 255))                                                                                                       
                                                                                                                                                                                   
    # 画像を縦に貼り付ける                                                                                                                                                         
    current_y = 0                                                                                                                                                                  
    for img in images:                                                                                                                                                             
        # 幅が異なる場合、中央揃えで貼り付け                                                                                                                                       
        x = (max_width - img.width) // 2                                                                                                                                           
        new_image.paste(img, (x, current_y))                                                                                                                                       
        current_y += img.height                                                                                                                                                    
                                                                                                                                                                                   
    # 保存                                                                                                                                                                         
    new_image.save(output_path)                                                                                                                                                    
    print(f"画像を縦に結合して {output_path} に保存しました。")                                                                                                                    
                                                                                                                                                                                   
if __name__ == '__main__':                                                                                                                                                         
    import requests                                                                                                                                                                
    url1 = "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEisQBhkfoss8nSml4HD1asLJJLOXRw6gE6kcLVnjt_RSC9Aa1lckUT_Q21I_gAZ2HMChhrGzQzryKjLGa2ScAQIZ9Lv4nW2YQBEb2Cw3ytU3lTcqf0kTgLrZ8fG7ZMklmUnQRiB3cYx10e1/s180-c/business_man_macho.png"                                                                                                                  
    url2 = "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEip4rhI9GLvB0DQIK1NVLhTUct3nVrUaRDaCV49YTLGYEoZTjoL_Qrs6BJEEw_sBvLeT7BBP3Cnmm6de5BRn1HTun_GkqD7zz_tIC9cnriMBDac3gwGawNaOhUO8i2s23hwSqth0-Wv3SXU/s180-c/bug_ari_hiari.png"                                                                                                                       
    url3 = "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgSszatjsjXHPBKzWoWm-qUctt0LqOFO_relakJsv6NSvm4m0Ma_XKvba95vQZWe3ACE_N0RhuSqzsd7bm8U0_zKAcuQ-nKce2OWwfpzPNoZ6SZ21ft853r0ZH0ene54dRpTWIe-vrM24oZ/s180-c/chounouryoku.png"                                                                                                                          
    concatenate_images_vertically([                                                                                                                                                
        requests.get(url1, stream=True).raw,                                                                                                                                       
        requests.get(url2, stream=True).raw,                                                                                                                                       
        requests.get(url3, stream=True).raw,                                                                                                                                       
    ], 'output.png')  
```

### 実行

画像を1枚だけ入力し、それぞれを説明させてみる

```python
messages = [                                                                                                                                                                       
    {"role": "user", "content": [                                                                                                                                                  
        {"type": "text", "text": "The following image contains three illustrations from top to bottom, please explain for each.\n"},                                               
        {"type": "image"},                                                                                                                                                         
    ]}                                                                                                                                                                             
]                                                                                                                                                                                  
processor = AutoProcessor.from_pretrained(model_id)                                                                                                                                
input_text = processor.apply_chat_template(messages, add_generation_prompt=True)                                                                                                   
images = [                                                                                                                                                                         
    Image.open('./output.png'),                                                                                                                                                    
]     
```

とscouter.pyを修正し、下記を実行

```bash
uv run scouter.py
```

### 結果

```
**上部イラスト**
上部のイラストは、男性が服を着て、拳を上げた戦闘姿で描かれています。男性は戦闘の準備ができているように見え、相手との戦いから自分自身や他人を守る準備ができていることを示唆していま
  。

**中間イラスト**
中間のイラストは、黄色い星を小さく持ち運ぶアントが描かれています。アントは動きに満ちたポーズで描かれており、速くて目的のある移動を示唆しています。黄色い星の存在は、アントが価値あ
  物や重要なものを運ぶ能力を表しています。

**下部イラスト**
下部のイラストは、男性が手を広げて、掌を向けている男性が描かれています。男性の表情は驚きや衝撃を受けたように見え、男性は突然や予期せぬ出来事に反応している可能性があります。また、
  況を鎮めようとしている可能性もあります。

3つのイラストは無関係のように見えますが、共通のテーマやアイデアによって結びついている可能性があります。シンプルな、明るい色で線を描いたイラストは、楽しく、気まぐれな雰囲気を与えて
  ます。
```

### 考察

三つの画像をそれぞれ独立して認識し、説明することができていそうである。

## 2. 上二つの画像を使って一番下の画像について推論できるか？


### 準備

入力画像は先ほどと同じ、縦に3枚画像をつなげたものを利用。

### 実行

```python                                              
messages = [                                                                                                                                                                       
    {"role": "user", "content": [                                                                                                                                                  
        {"type": "text", "text": "The following image contains three illustrations from top to bottom. The character in the top image has a combat power of 5, and the character in the middle has a combat power of 10. Please guess the fighting power of the character at the bottom based on the characteristics of the picture.\n"},                             
        {"type": "image"},                                                                                                                                                         
    ]}                                                                                                                                                                             
]                                                                                                                                                                                  
processor = AutoProcessor.from_pretrained(model_id)                                                                                                                                
input_text = processor.apply_chat_template(messages, add_generation_prompt=True)                                                                                                   
images = [                                                                                                                                                                         
    Image.open('./output.png'),                                                                                                                                                    
]     
```

とscouter.pyを修正し、下記を実行

```bash
uv run scouter.py
```

### 結果

```
「下のキャラクターの戦闘力を見つけるには、画像の特徴を分析し、与えられた情報に基づいて学んだ推測を行うことができます。

**与えられた情報:**

* 上の画像のキャラクターの戦闘力は5です。
* 中の画像のキャラクターの戦闘力は10です。

**分析:**

* 上の画像のキャラクターはマーシャルアーティストまたはファイターのような表情とダイナミックなポーズを持っています。
* 中の画像のキャラクターは強力な戦士またはモンスターのような大きい体と鋭い爪を持っています。
* 下のキャラクターは若い少年のような表情と手の周りで光るauraを持っています。

**推測:**

画像の特徴に基づくと、下のキャラクターの戦闘力は他の2つのキャラクターと比較して比較的低い可能性があります。少年の年齢と小さなサイズは、他の2つのキャラクターと比較して彼が強いこと
  経験が少ないことを示唆しています。

**答え:**

下のキャラクターの戦闘力はおよそ3であると想定されています。これは、キャラクターの力がサイズと経験が増加するにつれて増加するという仮定に基づいています。少年が下のキャラクターである
  め、他の2つのキャラクターと比較して小さく、経験が少ないと想定されています。
```

### 考察

few shotとして捉えられていそうな気がする。*分析*の項では戦闘力に影響しそうな特徴が述べられているため、事前のプロンプトの情報を使って画像の特徴をテキスト化し、その文章をもとに推論をすることができているのではないかと考えられる。

## 追記のまとめ

1枚しか画像は入力できないものの、画像を結合して1枚の画像として提供することによって推論させることはできそうである。
とはいえ、入力画像が224x224など正方形画像に前処理でまとめられている場合、解像度が高いものだと情報が欠損してしまう可能性もあるので、その辺りの考慮も必要だとは思われる。

# 参考資料

- [LLama3.2 11B にて画像入力を試す](https://zenn.dev/techpit/articles/llama3-2_vision_test)