GPT-4 with Visionを利用した画像の説明生成

# はじめに
この記事は、個人開発の際に得た知識を、いつでも見返せるように保存する目的で使用しているため、言葉足らずかつ不適切である箇所が多々あるかと思います。気付いた箇所がありましたら、ご一報いただけると幸いです。

最近GPT-4に触れる機会に恵まれ、いろいろ触ってみたりしていたのですが、[GPT-4 with Vision](https://platform.openai.com/docs/guides/vision)がリリースされ画像や動画を入力とするアプリケーションの作成が可能になりました。本記事では、GPT-4 with Visionに画像とそれに関連する質問を投げ、結果を取得する一連の機能をPythonを用いて実装していきます。

# 試した環境
作者は以下の環境を使用しています。そのほかの環境では試験してないので、動作の保証はありません。
|OS|MacOS 13.0|
|:---|:---|
|Python | 3.9.6|

[OpenAI公式のドキュメント](https://platform.openai.com/docs/quickstart?context=python)によると`Python>=3.7.1`が必要とのことです。

# APIキー、OpenAI Python Libraryの入手
はじめにOpenAI accoutを作成する必要があります。その後サインインを行い、APIキーを発行してください。発行したAPIキーは`.zshrc`などに環境変数として設定し、秘匿してください。
```bash:.zshrc
export OPENAI_API_KEY='hogehogepiyopiyo'
```
次にOpenAI Python Libraryをインストールします。コマンド1つで入手できます。
```bash
pip install --upgrade openai
```
GPT-4 With Visionの利用は現在有料です。料金は消費トークン量で決定されます。利用の上限設定などの設定は、サインイン後のサイドバー内の`Usage`や`Setting`から可能です。

# 実装
はじめに今回作成した物を載せます。その後、それぞれのコンポーネントの説明を行います。
```python:run_gpt4v_on_image.py
import os
import cv2
import base64
from openai import OpenAI
import numpy as np
from typing import Union

# Logging
import logging
logger = logging.getLogger(os.path.basename(__file__))
logger.setLevel(logging.INFO)
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
handler_format = logging.Formatter('%(asctime)s : [%(name)s - %(lineno)d] %(levelname)-8s - %(message)s')
stream_handler.setFormatter(handler_format)
logger.addHandler(stream_handler)

class RunGPT4OnImage():
  def __init__(self, api_key: Union[str, None]=None, model: str='gpt-4-vision-preview', max_tokens_per_call: int=200) -> None:
    # GPT-4 with vision
    self.OPENAI_API_KEY=api_key if not api_key is None else os.environ['OPENAI_API_KEY']
    self.client = OpenAI(api_key=self.OPENAI_API_KEY)
    self.payload = {
      'model': model,
      'messages': [], # ここに質問や画像などを並べてモデルに投げる。
      'max_tokens': max_tokens_per_call,
    }

  def encode_image_path(self, input_image_path: str) -> str:
    with open(input_image_path, 'rb') as image_file:
      return base64.b64encode(image_file.read()).decode('utf-8')

  def encode_image_array(self, input_image: np.ndarray) -> str:
    ret, buffer = cv2.imencode('.png', input_image)
    if ret:
      return base64.b64encode(buffer).decode('utf-8')

  def add_message_entry_as_specified_role(self, role: str) -> None:
    self.payload['messages'].append({'role': role, 'content': []})

  def add_text_content(self, text: str):
    self.payload['messages'][0]['content'].append(
      {'type': 'text', 'text': text}
    )

  def add_urlimage_content(self, urlimage: str, detail: str='auto') -> None:
    self.payload['messages'][0]['content'].append(
      {'type': 'image_url', 'image_url': {'url': urlimage}, 'detail': detail}
    )

  def add_b64image_content(self, b64image: str, detail: str='auto') -> None:
    self.payload['messages'][0]['content'].append(
      {'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{b64image}', 'detail': detail}}
    )

  def add_content(self, contents: dict, as_type: str) -> None:
    if as_type == 'text':
      self.add_text_content(input_message_in_ja=contents['message'], glossary_name=contents['glossary'])
    if as_type == 'urlimage':
      self.add_urlimage_content(url=contents['url'], detail=contents['details'])
    if as_type == 'b64image':
      self.add_b64image_content(b64image=contents['b64image'], detail=contents['details'])

  def delete_messages(self) -> None:
    self.payload['messages'] = []

  def delete_content(self, index: int=-1) -> None:
    del self.payload['messages'][0]['content'][index]

  def print_payload(self) -> dict:
    logger.info(self.payload)
    return self.payload

  def execute(self) -> str:
    result = self.client.chat.completions.create(**self.payload)

    logger.info(f'Finish reason: {result.choices[0].finish_reason}')
    logger.info(f'Created: {result.created}')
    logger.info(f'ID: {result.id}')
    logger.info(f'Usage')
    logger.info(f'  Completion tokens: {result.usage.completion_tokens}')
    logger.info(f'  Prompt tokens: {result.usage.prompt_tokens}')
    logger.info(f'  Total tokens: {result.usage.total_tokens}')
    logger.info(f'Model output: {result.choices[0].message.content}')

    return result.choices[0].message.content

if __name__ == '__main__':
  input_image_path = '../assets/hogehoge.jpg'

  gpt4v = RunGPT4VOnImage()
  gpt4v.add_message_entry_as_specified_role(role='user')
  gpt4v.add_text_content(text='What do you see in this image?')
  b64image = gpt4v.encode_image_path(input_image_path=input_image_path)
  gpt4v.add_b64image_content(b64image=b64image)
  _ = gpt4v.execute()
```

## OpenAI Python Libraryの利用、APIキーの設定
他のパッケージで利用することも考慮して引数としてAPIキーを入手出来るようにしていますが、基本的には先ほど環境変数として指定した値を読み込みます。 

画像の入力に対応した学習済みモデルは`gpt-4-vision-preview`です。`self.payload.messages`に質問文や画像を追加します。
```python:run_gpt4v_on_image.py
...
from openai import OpenAI
...
class RunGPT4VOnImage():
  def __init__(self, api_key: Union[str, None]=None, model: str='gpt-4-vision-preview', max_tokens_per_call: int=200) -> None:
    # GPT-4 with vision
    self.OPENAI_API_KEY=api_key if not api_key is None else os.environ['OPENAI_API_KEY']
    self.client = OpenAI(api_key=self.OPENAI_API_KEY)
    self.payload = {
      'model': model,
      'messages': [], # ここに質問や画像などを並べてモデルに投げる。
      'max_tokens': max_tokens_per_call,
    }
```

## 画像の指定方法
画像をモデルに投げるためには、2つの既定のパターンいずれかに従う必要があります。
1. ローカルに画像がある場合は、base64エンコードフォーマットに変換する。
1. URLを指定する

`detail`は3つのオプション(`low`, `high`, `auto`)から1つを選択する。デフォルトでは、`auto`が使用され、入力画像のサイズに応じて`low`か`high`を使用するか決定する。
```python:run_gpt4v_on_image.py
  def add_b64image_content(self, b64image: str, detail: str='auto') -> None:
    self.payload['messages'][0]['content'].append(
      {'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{b64image}', 'detail': detail}}
    )

  def add_urlimage_content(self, urlimage: str, detail: str='auto') -> None:
    self.payload['messages'][0]['content'].append(
      {'type': 'image_url', 'image_url': {'url': urlimage}, 'detail': detail}
    )
```

## 画像のエンコーディング
ローカル画像をbase64エンコーディングします。ファイルパスまたは、`OpenCV`などを利用し`numpy.ndarray`として扱う場合の2パターンを示します。
```python:run_gpt4v_on_image.py
  def encode_image_path(self, input_image_path: str) -> str:
    with open(input_image_path, 'rb') as image_file:
      return base64.b64encode(image_file.read()).decode('utf-8')

  def encode_image_array(self, input_image: np.ndarray) -> str:
    ret, buffer = cv2.imencode('.png', input_image)
    if ret:
      return base64.b64encode(buffer).decode('utf-8')
```

## モデルに投げる
モデルに`self.payload`の中身を投げます。戻り値の`result`には消費したトークンなどの情報が付与されてます。
```python:run_gpt4v_on_image.py
  def execute(self) -> str:
    result = self.client.chat.completions.create(**self.payload)
```
質問に対するモデルの回答は`result.choices[0].message.content`から取得可能です。

## 使用の流れ
下の入力画像`../assets/hogehoge.png`に対して「What do you see in this image?」という質問をします。その結果以下の回答が得られます。内容も概ね良さそうです。結果は全く同じなることはなく、毎回異なります。
```python:run_gpt4v_on_image.py
  input_image_path = '../assets/hogehoge.jpg'

  gpt4v = RunGPT4VOnImage()
  gpt4v.add_message_entry_as_specified_role(role='user')
  gpt4v.add_text_content(text='What do you see in this image?')
  b64image = gpt4v.encode_image_path(input_image_path=input_image_path)
  gpt4v.add_b64image_content(b64image=b64image)
  _ = gpt4v.execute()
```
![hogehoge.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3626697/2afcd07f-b4cc-6152-6a98-ab7a44ffb8a9.jpeg)

>This image shows a group of five adorable kittens sitting in grass. They all have various fur patterns and colors ranging from orange to stripes of gray and brown, with white accents. The kittens have bright, engaging eyes and seem to be looking curiously at something in the distance. It's a cute and wholesome scene depicting the playful and inquisitive nature of young felines.

複数の画像を`self.payload.messages`に加えてやり、画像の連続性などを認識させることも可能です。これを応用し、動画に対して自動で実況を付ける試みをされている方もいるようで、利用用途は十人十色であると感じました。とは言え、動画に対する利用は消費トークンが膨大になってしまうので、商用の利用はまだ先の話なのかもしれません。

# GPT-4 With Visionが不得意なこと

**以下はOpenAIが公開しているドキュメントを訳した物です。**
GPT-4 with visionは非常にパワフルで様々な状況に適応可能ですが、とは言え限界もあることを認識することは重要です。例えば以下のような利用には向いていません。

- 医療関係の画像: CTスキャン画像を代表するような医療関係の画像を認識するのには向いていない。
- ラテン語系言語以外の文字が含まれる画像: 日本語や韓国語などが含まれた画像の扱いは不得意。
- 小さいテキスト: 画像内のテキストを大きくするが、重要な細部は切り取らないようにする
- 回転: 回転や上下反転した文字・図形を誤って認識する可能性がある。
- 空間的推論: チェス盤上にある駒の位置の正確な位置推論などは苦手。
- 正確性: 特定のシナリオ下では不正確な説明やキャプションを生成する可能性がある。
- 特徴的な入力画像: パノラマ画像や魚眼画像に対して正常に動作しない。
- メタデータとリサイズ: 元のファイル名やメタデータは処理されず、画像は分析前にリサイズされ、元のサイズに影響を与えます。
- カウンティング: 画像ないのオブジェクト数計測は真の値からずれうる。
- CAPTCHAS: CAPTCHASを送信することはできません。

# 計算コスト
画像をモデルに入力することで消費されるトークンは、画像サイズと`detail`オプションで決定される。`detail: low`が指定された画像はすべて**85トークン**。`detail: high`が指定された画像は以下の手順が踏まれる。
1. 画像の縦横比を保存した状態で**2048px × 2048px**のサイズに含まれるようにスケールする。
1. 短辺が**768px**になるようにスケールする。
1. 最後にスケールされた画像が**512px × 512px**の正方形いくつで完全にカバーされるか測定する。

**512px × 512px**の正方形1つにつき**170トークン**、それに加え常に**85トークン**消費する。

例えば、OpenAIが公開したドキュメントでは以下の3例が消費するトークンが紹介されています。
- `detail: high`が指定された**1024px × 1024px**の画像
    1. 長辺が**2048px**以下なのでこのステップはスキップ
    1. 短辺が**1024px**のため**768px × 768px**にスケールされる
    1. 4つの**512px × 512px**が必要であるため、`170 * 4 + 85 = 765`トークンを消費

- `detail: high`が指定された**2048px × 4096px**の画像
    1. **1024px × 2048px**にスケールする
    1. 短辺が**1024px**のため、**768px × 1536px**にスケールダウンする
    1. 6つの**512px × 512px**が必要であるため、`170 * 6 + 85 = 1105`トークンを消費

- `detail: low`が指定された**4096px × 8192px**の画像
    - インプットサイズに依らず、`detail: low`は常に85トークンを消費

# さいごに
実装自体は思ったより簡単に作成できてしまい、その背後で使用されている技術に触れることなく機能を利用できてしまいます。モデルから返された結果は、当初の予想よりも細部まで説明されており、”モデルが目を持った"と言われる訳が少し分かった気がしました。
お読みいただきありがとうございました。