こんにちは!逆瀬川 ( https://x.com/gyakuse ) です!
このアドベントカレンダーでは生成AIのアプリケーションを実際に作り、どのように作ればいいのか、ということをわかりやすく書いていければと思います。アプリケーションだけではなく、プロダクト開発に必要なモデルの調査方法、training方法、基礎知識等にも触れていければと思います。
今回の記事について
最近発表された Gemini 2.0 Flash exp をベースにさまざまな実験をやっていきたいと思います。
Google AI Studio で Multimodal API を使ってみる
Google AI Studio にアクセスするとすぐ使えます。
現在のところ、Multimodal APIは英語オンリーとしていますが、日本語が使えます。system instructionに明示的に日本語であることを記述するとより安定します。
Multimodal API のデモアプリケーションを使ってみる
こちらのデモアプリケーションを動かします。
APIの制限
Multimodal APIの仕様:
- 1セッションあたりの最大継続時間
- 音声のみの場合は最大15分まで
- 音声 + 動画は最大2分まで
- レート制限
- API キーごとに最大同時3セッション
- 1分あたり400万トークン
デモアプリの動かし方
上記にある通り、
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) とクライアントを動かします。
python3 main.py
python3 -m http.server
かつ、以下よりGoogle Cloudのトークンを取得します。
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 に置いておきます。
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 で簡易に検証できます。
実験手法
Appendixにスクリプトを置いておきます。
実験1: 請求書
以前のgeminiでも同等程度の能力でしたが、bounding boxの座標は若干よくなっています。
実験2: 麻雀牌
これはテキスト抽出能力ではありませんが、一応やっておきます
上記記事で使った麻雀牌を使います。
Appendix
websocket-demo-app 画面共有機能更新版
<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:
pip install google-genai supervision
実装:
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
使用:
image_path = "../data/mahjong.png"
prompt = "麻雀牌の2D境界ボックスを検出します(牌種の説明として “label” を使用)"
annotated_image = detect_objects_with_gemini(image_path, prompt, api_key="{API KEY}")
annotated_image.show()