LangChain+Streamlitでオーディオガイドを自作してみた

GPTを使って簡単なアプリ開発をしてみたい！と思い、LLM開発のライブラリであるLangChainで遊んでみました。
私は最近美術館巡りにハマっているので、お家でも簡易鑑賞体験ができればと思い、好きな画像の解説をしてくれるLLMオーディオガイドを作ってみました。

コードはここにあります↓

https://github.com/doda999/LLM-AudioGuide


# 概要
まず入力画像をGPT4-Vで読み取り作品名と画家名を取得します。次に作品名と画家名をGPT3.5に入力し、オーディオガイドのような作品説明を生成します。
画像認識と文章生成の2段階に分けたのは、単純にマルチモーダルLLMと従来LLMの両方に触れてみたかったからです。
ただGPT4は2021年9月までのデータで訓練しているため、最新作だと作品を認識できない可能性があります。そのため作品名と画家名を出力できない場合には、GPT4-Vに絵画説明も委ねることにします。

# 画像認識
GPT-4Vの[コスト体系](https://platform.openai.com/docs/guides/vision/calculating-costs)によると、512x512の正方ごとに170トークンが加算されます。そこでコスト削減のため、最初に入力画像のアスペクト比を保ったまま長辺が512になるようにリサイズをかけます。

<details><summary>コード</summary>

```python:img_understanding.py
import cv2

def resize(img, max_length=512):
  h, w, c = img.shape
  if max(h, w) > max_length:
    if h > w:
      w = int(w * max_length / h)
      h = max_length
    else:
      h = int(h * max_length / w)
      w = max_length
  return cv2.resize(img, (w, h))
```
</details>

画像をbase64エンコードします。

<details><summary>コード</summary>

```python:img_understanding.py
import cv2
import base64

def encode_image(img):
  _, enc_data = cv2.imencode('.jpg', img)
  base64_image = base64.b64encode(enc_data).decode('utf-8')
  return base64_image
```
</details>

エンコード済み画像をGPTに渡します。作品名と画家名を明示的に取り出すため、出力はJSON形式を指定します。

<details><summary>コード</summary>

```python:img_understanding.py
import requests
import re
import json

def get_image_info(img):
  img_resize = resize(img)
  base64_image = encode_image(img_resize)
  
  prompt = """Tell me the title and the painter of the picture in JSON format.
  If you don't know the picture, please return empty data in JSON format. 
  
  Here is an example.
  {
    "title": "AB",
    "painter": "CD"
  }
  """
  
  headers = {
    "Content-Type": "application/json",
    "Authorization": f"Bearer {YOUR_OPENAI_API_KEY}"
  }
  
  payload = {
    "model": "gpt-4-vision-preview",
    "messages": [
      {
        "role": "user",
        "content": [
          {
            "type": "text",
            "text": prompt
          },
          {
            "type": "image_url",
            "image_url": {
              "url": f"data:image/jpeg;base64,{base64_image}"
            }
          }
        ]
      }
    ],
    "max_tokens": 100
  }
  
  response = requests.post("https://api.openai.com/v1/chat/completions", headers=headers, json=payload)
  data = response.json()['choices'][0]["message"]["content"]
  
  # jsonデータの取り出し
  pattern = r"```json([^`]+)"
  match = re.search(pattern, data)
  if match is None:
    return "", ""
  json_str = match.group(1)
  json_data = json.loads(json_str)
  
  # 作品名と画家名の抽出
  title = json_data.get("title", "")
  painter = json_data.get("painter", "")
  return title, painter
```
</details>

もし作品を認識できず作品名・画家名がわからなかった場合は、GPT4-Vに絵画説明を生成させます。

<details><summary>コード</summary>

```python:img_understanding.py
import requests

def get_caption_from_img(img):
  img_resize = resize(img)
  base64_image = encode_image(img_resize)
  
  prompt ="""Explain briefly about the art techniques and its effects in the picture by mentioning where in the picture each technique is used.  
  The answer should be sentences, not in bullet points."""
  
  headers = {
    "Content-Type": "application/json",
    "Authorization": f"Bearer {YOUR_OPENAI_API_KEY}"
  }
  
  payload = {
    "model": "gpt-4-vision-preview",
    "messages": [
      {
        "role": "user",
        "content": [
          {
            "type": "text",
            "text": prompt
          },
          {
            "type": "image_url",
            "image_url": {
              "url": f"data:image/jpeg;base64,{base64_image}"
            }
          }
        ]
      }
    ],
    "max_tokens": 200
  }
  
  response = requests.post("https://api.openai.com/v1/chat/completions", headers=headers, json=payload)
  caption = response.json()['choices'][0]["message"]["content"]
  return caption
```
</details>

# 絵画検索
オーディオガイドは作品によってスタイルが異なり、強調するポイントや詳細度も様々です。今回のオーディオガイドでは、一般的な2つのポイントを中心に絵画解説を作成することにします。
+ 絵画が描かれた背景
+ 絵画の美術技法

単純に質問するだけだと回答が冗長になるため、[Chains](https://python.langchain.com/docs/modules/chains)を用い要約処理を後続させることで説明の簡潔化を図ります。

<details><summary>コード</summary>

```Python:text_generation.py
from langchain.chat_models import ChatOpenAI
from langchain.chains import SimpleSequentialChain, LLMChain
from langchain.prompts import PromptTemplate

def get_caption_from_info(title, painter):
    chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.2)

    # 質問LLM
    q_template = """Tell me about the historical or cultural background which influenced {input} and 
    explain briefly about the art techniques and its effects in the picture by mentioning where in the picture each technique is used."""
    q_prompt = PromptTemplate(input_variables=["input"], template=q_template)
    q_chain = LLMChain(llm=chat, prompt=q_prompt)
    
    # 要約LLM
    s_template = """Summarize the sentences below.
    {input}
    """
    s_prompt = PromptTemplate(input_variables=["input"], template=s_template)
    s_chain = LLMChain(llm=chat, prompt=s_prompt)
    
    # 2つのLLMを連結
    seq_chain = SimpleSequentialChain(
        chains=[q_chain, s_chain]
    )
    
    painting = f"{title} by {painter}"
    result = seq_chain(painting)
    return result["output"]
```
</details>

# StreamlitによるUI作成
UIにはStreamlitというパッケージを使います。Streamlitを使えば、少ないコード量で簡単にWeb UIを実現できます。

画面上部に画像ローダを配置し、画像を読み込むとローダの下に画像と説明文を表示するようにします。

<details><summary>コード</summary>

```Python:app.py
import streamlit as st
import cv2
import numpy as np

from img_understanding import *
from text_generation import *

st.title("Audio Guide Maker")

file_path = st.file_uploader('Choose a file', type=['png', 'jpg', 'jpeg'])
if file_path:
    # バイト列を読み込む
    image_bytes = file_path.read()
    # バイト列からデコード
    img = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR)
    # 画像表示
    st.image(img, channels="BGR")
    
    # 作品名と画家名を取得
    title, painter = get_image_info(img)
    # 情報不明の場合、画像から説明文生成
    if title == "" or painter == "":
        caption = get_caption_from_img(img)
    # 情報が取得できた場合、作品名と画家名から説明文生成
    else:
        caption = get_caption_from_info(title, painter)
    
    st.write(caption)
```
</details>

# 参考文献
+ [ChatGPT/LangChainによるチャットシステム構築[実践]入門](https://gihyo.jp/book/2023/978-4-297-13839-4)
+ [GPT-4 Vision](https://platform.openai.com/docs/guides/vision)
+ [Streamlit](https://streamlit.io/)
+ [Streamlitで画像を読み込んで表示させたい](https://qiita.com/inoshun/items/ded26487bf0065794d2c)