Gemini Multimodal APIで画面共有しながらAIと会話をする & Gemini 2.0 の OCR 性能を測ってみる！

こんにちは！逆瀬川 ( https://x.com/gyakuse ) です！

このアドベントカレンダーでは生成AIのアプリケーションを実際に作り、どのように作ればいいのか、ということをわかりやすく書いていければと思います。アプリケーションだけではなく、プロダクト開発に必要なモデルの調査方法、training方法、基礎知識等にも触れていければと思います。


<iframe width="560" height="315" src="https://www.youtube.com/embed/bY_UmY7WebU" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

## 今回の記事について

最近発表された Gemini 2.0 Flash exp をベースにさまざまな実験をやっていきたいと思います。

## Google AI Studio で Multimodal API を使ってみる

[Google AI Studio](https://aistudio.google.com/app/live) にアクセスするとすぐ使えます。

[現在のところ、Multimodal APIは英語オンリーとしていますが](https://cloud.google.com/vertex-ai/generative-ai/docs/gemini-v2#live-api)、日本語が使えます。system instructionに明示的に日本語であることを記述するとより安定します。

<iframe width="560" height="315" src="https://www.youtube.com/embed/bY_UmY7WebU" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

## Multimodal API のデモアプリケーションを使ってみる

https://github.com/GoogleCloudPlatform/generative-ai/tree/main/gemini/multimodal-live-api/websocket-demo-app

こちらのデモアプリケーションを動かします。

### APIの制限

Multimodal APIの仕様:

https://ai.google.dev/api/multimodal-live

- 1セッションあたりの最大継続時間
    - 音声のみの場合は最大15分まで
    - 音声 + 動画は最大2分まで
- レート制限
    - API キーごとに最大同時3セッション
    - 1分あたり400万トークン

### デモアプリの動かし方

https://github.com/GoogleCloudPlatform/generative-ai/tree/main/gemini/multimodal-live-api/websocket-demo-app

上記にある通り、

```bash
git clone https://github.com/GoogleCloudPlatform/generative-ai.git
cd gemini/multimodal-live-api/websocket-demo-app
python3 -m venv env
source env/bin/activate
pip3 install -r requirements.txt
```

このように準備して、websocketサーバー (main.py) とクライアントを動かします。

```bash
python3 main.py
```

```bash
python3 -m http.server
```

かつ、以下よりGoogle Cloudのトークンを取得します。

```bash
gcloud config set project YOUR-PROJECT-ID
gcloud auth print-access-token
```

これで localhost:8000 にアクセスし、トークンを貼り付け、`projects/YOUR-PROJECT-ID/locations/us-central1/publishers/google/models/gemini-2.0-flash-exp` の `YOUR-PROJECT-ID` 部分を自身のプロジェクトIDに置き換えて CONNECT を押下すれば開始します。

### 画面共有を使う

demo appではWebカメラのアクセスしか行わないため、toggleで画面共有と切り替えられるようにします。
更新分の `index.html` は Appendix に置いておきます。

```js
async function startScreenShare() {
  stopCurrentStream();
  try {
    // getDisplayMediaで画面共有ストリーム取得
    stream = await navigator.mediaDevices.getDisplayMedia({
      video: {
        width: { max: 640 },
        height: { max: 480 },
      },
      audio: false, // 必要に応じてtrueにする
    });
    video.srcObject = stream;
  } catch (err) {
    console.error("Error accessing the screen: ", err);
  }
}

// ウェブカメラ / 画面共有 切り替え
function toggleMediaSource() {
  isWebcamMode = !isWebcamMode;
  if (isWebcamMode) {
    startWebcam();
  } else {
    startScreenShare();
  }
}
```

## Gemini 2.0 の画像からのテキスト抽出性能

### 簡単に試す方法

こちらも Google AI Studio で簡易に検証できます。

https://aistudio.google.com/app/starter-apps/spatial

### 実験手法

Appendixにスクリプトを置いておきます。

### 実験1: 請求書

以前のgeminiでも同等程度の能力でしたが、bounding boxの座標は若干よくなっています。

![01-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958603/2dea84e1-3471-0038-8e64-abed655779fb.png)

### 実験2: 麻雀牌

これはテキスト抽出能力ではありませんが、一応やっておきます

https://qiita.com/sakasegawa/items/676edabb25ed562d61d9

上記記事で使った麻雀牌を使います。

![mahjong_detect.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2958603/09d25c20-fd76-31c8-999d-8fc7a7de19ba.png)


## Appendix

### websocket-demo-app 画面共有機能更新版

```html
<html>
  <head>
    <link
      rel="stylesheet"
      href="https://fonts.googleapis.com/icon?family=Material+Icons"
    />
    <link
      rel="stylesheet"
      href="https://code.getmdl.io/1.3.0/material.indigo-pink.min.css"
    />
    <script defer src="https://code.getmdl.io/1.3.0/material.min.js"></script>

    <style>
      #videoElement {
        width: 320px;
        height: 240px;
        border-radius: 20px;
      }

      #canvasElement {
        display: none;
      }

      .demo-content {
        padding: 20px;
        display: flex;
        flex-direction: column;
        align-items: center;
      }

      .button-group {
        margin-bottom: 20px;
      }
    </style>
  </head>

  <body>
    <div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">
      <header class="mdl-layout__header">
        <div class="mdl-layout__header-row">
          <!-- Title -->
          <span class="mdl-layout-title">Demo</span>
        </div>
      </header>
      <main class="mdl-layout__content">
        <div class="page-content">
          <div class="demo-content">
            <!-- Connect Button -->
            <div class="mdl-textfield mdl-js-textfield">
              <input class="mdl-textfield__input" type="text" id="token" />
              <label class="mdl-textfield__label" for="input"
                >Access Token...</label
              >
            </div>
            <div class="mdl-textfield mdl-js-textfield">
              <input
                class="mdl-textfield__input"
                type="text"
                id="model"
                value="projects/YOUR-PROJECT-ID/locations/us-central1/publishers/google/models/gemini-2.0-flash-exp"
              />
              <label class="mdl-textfield__label" for="input">Model ID</label>
            </div>
            <button
              onclick="connect()"
              class="mdl-button mdl-js-button mdl-button--raised mdl-button--colored"
            >
              Connect
            </button>

            <!-- Button Group -->
            <div class="button-group">
              <!-- Text Input Field -->
              <div class="mdl-textfield mdl-js-textfield">
                <input class="mdl-textfield__input" type="text" id="input" />
                <label class="mdl-textfield__label" for="input"
                  >Enter text message...</label
                >
              </div>

              <!-- Send Message Button -->
              <button
                onclick="sendUserMessage()"
                class="mdl-button mdl-js-button mdl-button--icon"
              >
                <i class="material-icons">send</i>
              </button>
            </div>

            <!-- Voice Control Buttons -->
            <div class="button-group">
              <button
                onclick="startAudioInput()"
                class="mdl-button mdl-js-button mdl-button--fab mdl-button--mini-fab mdl-button--colored"
              >
                <i class="material-icons">mic</i>
              </button>
              <button
                onclick="stopAudioInput()"
                class="mdl-button mdl-js-button mdl-button--fab mdl-button--mini-fab"
              >
                <i class="material-icons">mic_off</i>
              </button>
            </div>

            <!-- Toggle Video Source Button -->
            <div class="button-group">
              <button
                onclick="toggleMediaSource()"
                class="mdl-button mdl-js-button mdl-button--raised mdl-button--colored"
              >
                Toggle Webcam / Screen
              </button>
            </div>

            <!-- Video Element -->
            <video id="videoElement" autoplay></video>

            <!-- Hidden Canvas -->
            <canvas id="canvasElement"></canvas>
            <div id="chatLog"></div>
          </div>
        </div>
      </main>
    </div>

    <script defer>
      const URL = "ws://localhost:8080";
      const video = document.getElementById("videoElement");
      const canvas = document.getElementById("canvasElement");
      const context = canvas.getContext("2d");
      const accessTokenInput = document.getElementById("token");
      const modelIdInput = document.getElementById("model");
      let stream = null; // Store the MediaStream object globally
      let isWebcamMode = true; // 現在Webカメラモードかどうか

      let currentFrameB64;

      // メディアストリーム停止のためのヘルパー
      function stopCurrentStream() {
        if (stream) {
          stream.getTracks().forEach((track) => track.stop());
          stream = null;
        }
      }

      // Webcam開始
      async function startWebcam() {
        stopCurrentStream();
        try {
          const constraints = {
            video: {
              width: { max: 640 },
              height: { max: 480 },
            },
          };

          stream = await navigator.mediaDevices.getUserMedia(constraints);
          video.srcObject = stream;
        } catch (err) {
          console.error("Error accessing the webcam: ", err);
        }
      }

      // 画面共有開始
      async function startScreenShare() {
        stopCurrentStream();
        try {
          // getDisplayMediaで画面共有ストリーム取得
          stream = await navigator.mediaDevices.getDisplayMedia({
            video: {
              width: { max: 640 },
              height: { max: 480 },
            },
            audio: false, // 必要に応じてtrueにする
          });
          video.srcObject = stream;
        } catch (err) {
          console.error("Error accessing the screen: ", err);
        }
      }

      // ウェブカメラ / 画面共有 切り替え
      function toggleMediaSource() {
        isWebcamMode = !isWebcamMode;
        if (isWebcamMode) {
          startWebcam();
        } else {
          startScreenShare();
        }
      }

      // Function to capture an image and convert it to base64
      function captureImage() {
        if (stream && video.videoWidth > 0 && video.videoHeight > 0) {
          canvas.width = video.videoWidth;
          canvas.height = video.videoHeight;
          context.drawImage(video, 0, 0, canvas.width, canvas.height);
          const imageData = canvas.toDataURL("image/jpeg").split(",")[1].trim();
          currentFrameB64 = imageData;
        }
      }

      window.addEventListener("load", startWebcam);
      setInterval(captureImage, 1000);

      let webSocket = null;

      function connect() {
        console.log("connecting: ", URL);

        webSocket = new WebSocket(URL);

        webSocket.onclose = (event) => {
          console.log("websocket closed: ", event);
          alert("Connection closed");
        };

        webSocket.onerror = (event) => {
          console.log("websocket error: ", event);
        };

        webSocket.onopen = (event) => {
          console.log("websocket open: ", event);
          sendInitialSetupMessage();
        };

        webSocket.onmessage = receiveMessage;
      }

      function sendInitialSetupMessage() {
        console.log("sending auth message");

        const accessToken = accessTokenInput.value;
        const modelId = modelIdInput.value;

        auth_message = {
          bearer_token: accessToken,
        };
        webSocket.send(JSON.stringify(auth_message));

        console.log("sending setup message");
        setup_client_message = {
          setup: {
            model: modelId,
            generation_config: { response_modalities: ["AUDIO"] },
          },
        };

        webSocket.send(JSON.stringify(setup_client_message));
      }

      function sendMessage(message) {
        if (webSocket == null) {
          console.log("websocket not initilised");
          return;
        }

        payload = {
          client_content: {
            turns: [
              {
                role: "user",
                parts: [{ text: message }],
              },
            ],
            turn_complete: true,
          },
        };

        webSocket.send(JSON.stringify(payload));
        console.log("sent: ", payload);
        displayMessage("USER: " + message);
      }

      function sendVoiceMessage(b64PCM) {
        if (webSocket == null) {
          console.log("websocket not initialized");
          return;
        }

        payload = {
          realtime_input: {
            media_chunks: [
              {
                mime_type: "audio/pcm",
                data: b64PCM,
              },
              {
                mime_type: "image/jpeg",
                data: currentFrameB64,
              },
            ],
          },
        };

        webSocket.send(JSON.stringify(payload));
        console.log("sent: ", payload);
      }

      function sendUserMessage() {
        const messageText = document.getElementById("input").value;
        console.log("user message: ", messageText);
        sendMessage(messageText);
      }

      let current_message = "";

      function receiveMessage(event) {
        const messageData = JSON.parse(event.data);
        console.log("messageData: ", messageData);
        const response = new Response(messageData);
        console.log("receiveMessage ", response);

        current_message = current_message + response.data;

        injestAudioChuckToPlay(response.data);

        if (response.endOfTurn) {
          // displayMessage("GEMINI: 🔊");
          // current_message = "";
        }
      }

      let audioInputContext;
      let workletNode;
      let initialized = false;

      async function initializeAudioContext() {
        if (initialized) return;

        audioInputContext = new (window.AudioContext ||
          window.webkitAudioContext)({ sampleRate: 24000 });
        await audioInputContext.audioWorklet.addModule("pcm-processor.js");
        workletNode = new AudioWorkletNode(audioInputContext, "pcm-processor");
        workletNode.connect(audioInputContext.destination);
        initialized = true;
      }

      function base64ToArrayBuffer(base64) {
        const binaryString = window.atob(base64);
        const bytes = new Uint8Array(binaryString.length);
        for (let i = 0; i < binaryString.length; i++) {
          bytes[i] = binaryString.charCodeAt(i);
        }
        return bytes.buffer;
      }

      function convertPCM16LEToFloat32(pcmData) {
        const inputArray = new Int16Array(pcmData);
        const float32Array = new Float32Array(inputArray.length);

        for (let i = 0; i < inputArray.length; i++) {
          float32Array[i] = inputArray[i] / 32768;
        }

        return float32Array;
      }

      async function injestAudioChuckToPlay(base64AudioChunk) {
        try {
          if (!initialized) {
            await initializeAudioContext();
          }

          if (audioInputContext.state === "suspended") {
            await audioInputContext.resume();
          }

          const arrayBuffer = base64ToArrayBuffer(base64AudioChunk);
          const float32Data = convertPCM16LEToFloat32(arrayBuffer);

          workletNode.port.postMessage(float32Data);
        } catch (error) {
          console.error("Error processing audio chunk:", error);
        }
      }

      let audioContext;
      let processor;
      let pcmData = [];
      let interval = null;

      function recordChunk() {
        // Convert to base64
        const buffer = new ArrayBuffer(pcmData.length * 2);
        const view = new DataView(buffer);
        pcmData.forEach((value, index) => {
          view.setInt16(index * 2, value, true);
        });

        const base64 = btoa(
          String.fromCharCode.apply(null, new Uint8Array(buffer))
        );

        sendVoiceMessage(base64);

        pcmData = [];
      }

      async function startAudioInput() {
        audioContext = new AudioContext({
          sampleRate: 16000,
        });

        const stream = await navigator.mediaDevices.getUserMedia({
          audio: {
            channelCount: 1,
            sampleRate: 16000,
          },
        });

        const source = audioContext.createMediaStreamSource(stream);
        processor = audioContext.createScriptProcessor(4096, 1, 1);

        processor.onaudioprocess = (e) => {
          const inputData = e.inputBuffer.getChannelData(0);
          // Convert float32 to int16
          const pcm16 = new Int16Array(inputData.length);
          for (let i = 0; i < inputData.length; i++) {
            pcm16[i] = inputData[i] * 0x7fff;
          }
          pcmData.push(...pcm16);
        };

        source.connect(processor);
        processor.connect(audioContext.destination);

        interval = setInterval(recordChunk, 1000);
      }

      function stopAudioInput() {
        if (processor && audioContext) {
          processor.disconnect();
          audioContext.close();
        }
        clearInterval(interval);
      }

      function displayMessage(message) {
        console.log(message);
        addParagraphToDiv("chatLog", message);
      }

      function addParagraphToDiv(divId, text) {
        const newParagraph = document.createElement("p");
        newParagraph.textContent = text;
        const div = document.getElementById(divId);
        div.appendChild(newParagraph);
      }

      class Response {
        constructor(data) {
          this.data = "";
          this.endOfTurn = data?.serverContent?.turnComplete;

          if (data?.serverContent?.modelTurn?.parts) {
            this.data = data?.serverContent?.modelTurn?.parts[0]?.text;
          }

          if (data?.serverContent?.modelTurn?.parts[0]?.inlineData) {
            this.data =
              data?.serverContent?.modelTurn?.parts[0]?.inlineData.data;
          }
        }
      }
    </script>
  </body>
</html>
```

### geminiでのbounding box 描画実装

supervisionを使います。
利用するライブラリは新しい `google-genai` を使います。

install:

```bash
pip install google-genai supervision
```

実装:

```python
import os
import json
import io
from PIL import Image
import supervision as sv
import numpy as np

from google import genai
from google.genai import types

def detect_objects_with_gemini(image_path: str, prompt: str, api_key: str, model_name: str = "gemini-2.0-flash-exp"):
    # Gemini用クライアント初期化
    client = genai.Client(api_key=api_key)

    # システム指示
    bounding_box_system_instructions = """
    Return bounding boxes as a JSON array with labels. Never return masks or code fencing. Limit to 25 objects.
    If an object is present multiple times, name them according to their unique characteristic (colors, size, position, unique characteristics, etc..).
    """

    safety_settings = [
        types.SafetySetting(
            category="HARM_CATEGORY_DANGEROUS_CONTENT",
            threshold="BLOCK_ONLY_HIGH",
        ),
    ]

    # 画像の読込
    img = Image.open(image_path)
    width, height = img.size

    response = client.models.generate_content(
        model=model_name,
        contents=[prompt, img], 
        config=types.GenerateContentConfig(
            system_instruction=bounding_box_system_instructions,
            temperature=0.5,
            safety_settings=safety_settings,
        )
    )

    # GeminiからのレスポンスにはJSONがテキストとして含まれるのでparse
    def parse_json(json_output: str):
        # JSONだけを抽出
        # 一部の例では"```json"で囲われているので取り除く
        lines = json_output.splitlines()
        for i, line in enumerate(lines):
            if line.strip() == "```json":
                json_output = "\n".join(lines[i+1:])
                json_output = json_output.split("```")[0]
                break
        return json_output

    bounding_boxes_json = parse_json(response.text)
    boxes_data = json.loads(bounding_boxes_json)

    # Geminiは0~1000正規化なので、実画像サイズにスケール変換
    # supervisionは[xmin, ymin, xmax, ymax]順序を期待
    xyxy_boxes = []
    labels = []
    for obj in boxes_data:
        y1, x1, y2, x2 = obj["box_2d"]
        # 正規化(0~1000) -> ピクセル座標へ変換
        xmin = (x1 / 1000.0) * width
        ymin = (y1 / 1000.0) * height
        xmax = (x2 / 1000.0) * width
        ymax = (y2 / 1000.0) * height

        # 座標順序の補正(念のため)
        xmin, xmax = sorted([xmin, xmax])
        ymin, ymax = sorted([ymin, ymax])
        xyxy_boxes.append([xmin, ymin, xmax, ymax])
        labels.append(obj.get("label", "object"))

    # SupervisionのDetectionsを作成
    # confは不明なので仮に1.0としますが、本来はconfidenceがあれば加える
    detections = sv.Detections(
        xyxy=np.array(xyxy_boxes, dtype=float),
        # クラスID等は任意、ラベル描画に必要なら固定値でも良い
        # 下記は例として全オブジェクトをクラスID=0とします
        class_id=np.zeros(len(xyxy_boxes), dtype=int),
        confidence=np.ones(len(xyxy_boxes), dtype=float)
    )

    # Annotatorで描画
    box_annotator = sv.BoxAnnotator()
    label_annotator = sv.LabelAnnotator()

    # 画像をnumpy配列に変換
    img_np = np.array(img)

    # バウンディングボックス描画
    annotated_img = box_annotator.annotate(
        scene=img_np.copy(),
        detections=detections
    )

    # ラベル描画
    annotated_img = label_annotator.annotate(
        scene=annotated_img,
        detections=detections,
        labels=labels
    )

    # PILイメージに戻す
    annotated_pil = Image.fromarray(annotated_img)
    return annotated_pil
```

使用:

```bash
image_path = "../data/mahjong.png"
prompt = "麻雀牌の2D境界ボックスを検出します（牌種の説明として “label” を使用）"
annotated_image = detect_objects_with_gemini(image_path, prompt, api_key="{API KEY}")
annotated_image.show()
```