【GAS】「Gemini Pro Vision」を使って画像を扱えるLINE botを作成する(後編)

皆さん、Googleが2023年12月7日にとんでもないものを発表したことをご存じでしょうか?
そう、Googleの生成系AI「Gemini」です

こーすけ先生

3つのモデルのうち、2023年12月13日から「Gemini Pro」だけ使えるようになっています!「Google×生成AI」これは使うしかないでしょ!

「Gemini Pro Vision」は、Googleが開発したマルチモーダル言語モデルです。
テキストと画像の両方を入力として受け入れ、テキスト出力として生成します。Gemini Pro Visionは、テキストと画像の間の関連性を理解し、テキスト出力をより関連性のあるものにすることができます。

これをLINE botに組み込めるの、超やばくないですか??

こーすけ先生

というワケで今回は、話題の「Gemini Pro Vision」を使って画像も扱えるLINE botを作っちゃいましょう!

本記事の内容
  • Googleの生成AI「Gemini Pro」を使ってオリジナルのLINE botを作る(前回)
  • LINEによる画像送信時のGoogleドライブへの一時保存する
  • 画像データをGemini Pro Visionに渡して回答を受領
こーすけ先生

前回の記事で作ったLINE botで画像を扱えるようにもう一段階レベルアップさせてみましょう!
この機会に、皆さんも話題の「Gemini Pro API」を使ってみてね!

この記事を読めば、「Gemini Pro Vision」が搭載された会話可能なLINE botが完成します!
かなりワクワクする内容だと思うので、是非読んでいってください!

こーすけ先生

本記事は全3回のうち、最終章です!
まだ見てない方はこちらから見てね!

この記事の執筆者について
  • GASの人
  • ITベンダSEとして12年勤務する中で民間、金融、官公庁の現場を一通り経験済
  • 現在は公務員をやりながら起業に向けて着々と準備中
GASなら任せろ!

​GASを極めたい方や、業務の効率化を図りたい方は、ぜひこの記事を読んください!
難しいことはGASに任せて、我々人間は楽しちゃいましょう!

こーすけ先生

当ブログでは実際に仕事でGASを扱っている私が、GASの魅力について徹底的に取り上げていきます!

目次

LINE botに「Gemini Pro Vision」を搭載しよう

本記事で取り組む演習
  • LINEによる画像送信時のGoogleドライブへの一時保存
  • 画像データをGemini Pro Visionに渡して回答を受領
こーすけ先生

完成形のイメージはこんなカンジ!
今回の記事では、前回作ったLINE botをさらにレベルアップさせて「Gemini Pro Vision」の機能も使えるようにしちゃいます!

サーティワンアイスクリームの写真を送ったところ、「サーティワンのストロベリーチーズケーキとチョコレートのアイスです」と送られてきました すごい

前回の記事で、GAS上で「Gemini Pro API」を動かしてみるところまでやりました

こーすけ先生

Gemini Pro Visionは、Googleが開発したマルチモーダル言語モデルです

Gemini Pro Visionは、テキストと画像の両方を入力として受け入れ、テキスト出力として生成します
テキストと画像の間の関連性を理解し、テキスト出力をより関連性のあるものにすることができます

【事前準備】画像を一時保存するためのフォルダを作成する

こーすけ先生

今回はGoogleドライブ上に画像ファイルを一時保存するため、一時保存領域を作成しておきましょう

こーすけ先生

私は同一フォルダ内にtmpを作成しました
このtmpのフォルダIDを確認しておいてください

【STEP1】LINEによる画像送信時のGoogleドライブへの一時保存

こーすけ先生

LINEでは画像とテキストを同時におくることはできません
そこで……以下のような仕様にしたいと思います!

  1. 画像を送信し、Googleドライブに一時保存する
  2. 次にテキストを送信し、一時保存された画像データとともにGemini Pro Visionに渡す
こーすけ先生

ここでLINEの「Messaging APIリファレンス」を確認してみましょう!

こちらを確認すると、contentProvider.typeline、つまりユーザーが画像ファイルを送信した場合

画像ファイルのバイナリデータは、メッセージIDを指定してコンテンツを取得するエンドポイントを使用することで取得できます。

Messaging APIリファレンスより
こーすけ先生

そこで、コンテンツを取得するエンドポイントも併せて確認しましょう!

curl -v -X GET https://api-data.line.me/v2/bot/message/{messageId}/content \
-H 'Authorization: Bearer {channel access token}'

{messageId}{channel access token}は実際の値に置き換える必要があります!

こーすけ先生

これにより、指定されたメッセージのコンテンツを取得することができます
そして、これを見ると、以下のことがわかります

スクロールできます
項目
Fetch先のURLhttps://api-data.line.me/v2/bot/message/{messageId}/content
method種別GET
認証Bearer {channel access token}

LINEのAPIエンドポイント(https://api-data.line.me/v2/bot/message/{messageId}/content)にGETリクエストを行うことで、指定された{messageId}に関連するメッセージのコンテンツを取得できます

const LINEAPI_TOKEN = '**LINEのチャネルアクセストークンを記載する**';
const OUT_DIR = '**一時保存領域のフォルダID**';

/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e){
  // イベントデータはJSON形式となっているため、parseして取得
  const eventData = JSON.parse(e.postData.contents).events[0]
        , repToken = eventData.replyToken
        , msgType = eventData.message.type;

  // テキストメッセージのとき
  if (msgType=='text') {
    let uText = eventData.message.text
        , gemini = getGeminiProAnswerTxt(uText);
    replyTxt(repToken, gemini);
    sCache.put('user', uText.slice(0, 10000));
    sCache.put('model', gemini.slice(0, 10000));

  // 画像メッセージのとき
  } else if (msgType=='image') {
    let imageId = getImageId4Create(eventData);
  }
}

/**
 * LINEのトークで送信された画像をGoogleドライブに保存し、ファイルIDを返却するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function getImageId4Create(e) {
  const url = 'https://api-data.line.me/v2/bot/message/' + e.message.id + '/content',
        options = {
          'method': 'get',
          'headers': {
            'Authorization': 'Bearer ' + LINEAPI_TOKEN,
          }
        };

  const data = UrlFetchApp.fetch(url, options)
        ,imageData = data.getBlob().getAs('image/png').setName(Number(new Date()));
  return DriveApp.getFolderById(OUT_DIR).createFile(imageData).getId();
}
  1. LINEのAPIエンドポイント( https://api-data.line.me/v2/bot/message/{messageId}/content )を使用して、ユーザーが送信した画像データを取得
  2. 取得した画像データをBlobとして取得し、ファイル名を日時で指定
  3. その後、Googleドライブの指定されたフォルダに画像を保存
  4. 保存した画像ファイルのファイルIDを取得して、それを返却
こーすけ先生

上記のようにmsgTypeで判別して、imageであった場合にはコンテンツを取得するエンドポイントを用いてデータを取得し、Googleドライブへ保存します

curl -v -X GET https://api-data.line.me/v2/bot/message/{messageId}/content \
-H 'Authorization: Bearer {channel access token}'

{messageId}{channel access token}は実際の値に置き換える必要があるので…

function getImageId4Create(e) {
  const url = 'https://api-data.line.me/v2/bot/message/' + e.message.id + '/content',
        options = {
          'method': 'get',
          'headers': {
            'Authorization': 'Bearer ' + LINEAPI_TOKEN,
          }
        };
こーすけ先生

こんなカンジで実際の値に置き換えているよ!

【STEP2】画像データをGemini Pro Visionに渡して回答を受領

こーすけ先生

これまではGemini Proを利用していましたが、今回はGemini Pro Visionを利用するため、APIの仕様を確認しましょう

echo '{
  "contents":[
    {
      "parts":[
        {"text": "What is this picture?"},
        {
          "inline_data": {
            "mime_type":"image/jpeg",
            "data": "'$(base64 -w0 image.jpg)'"
          }
        }
      ]
    }
  ]
}' > request.json

curl https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${API_KEY} \
        -H 'Content-Type: application/json' \
        -d @request.json 2> /dev/null | grep "text"

これまではcontentsには'parts' > 'text'のみ(roleの指定もあり)でしたが、ここにinline_dataを指定し、database64で渡せば、Gemini Pro Visionは受け付けてくれるようです

base64エンコード方式は、印字可能な英数字64種類(A – Z、 a – z、 0 – 9 、+、/)のみを用いてデータを変換しています

また、今回の場合は画像メッセージを送信し、次にテキストメッセージを送信すると2つのイベントが発生しますが、GAS側から見るとこの2つのイベントは独立しています

こーすけ先生

そのため、後者のテキストメッセージを送信した際にその前にユーザが画像を送信していたという事実をGAS側は何らかの手段を使わないと理解できません

がすぴょん

えっじゃあ、どうすれば良いの?

こーすけ先生

ここで登場するのが、前回ご紹介した Cache です

つまり、画像データをGoogleドライブに保存し、この際のファイルIDをScriptCacheに保存しておきます
あとはテキストメッセージを受信した際に、ScriptCacheが存在するか否かで処理を分岐させればバッチリです!

こーすけ先生

では、やってみましょう!

const GEMINI_API = '**「Gemini Pro」で取得したAPI keyを記載する**';
const REPLY_URL = 'https://api.line.me/v2/bot/message/reply';
const LINEAPI_TOKEN = 'LINEで取得したチャネルアクセストークンを記載する**';

const OUT_DIR = '**一時保存領域のフォルダID**';
const sCache = CacheService.getScriptCache();

/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e) {
  // イベントデータはJSON形式となっているため、parseして取得
  const eventData = JSON.parse(e.postData.contents).events[0],
    repToken = eventData.replyToken,
    msgType = eventData.message.type;

  // テキストメッセージのとき
  if (msgType == 'text') {
      gemini;
    if (!sCache.get('image')) {
      gemini = getGeminiProAnswerTxt(uText);
    } else {
      gemini = getGeminiProVisionAnswerTxt(uText, sCache.get('image'));
      DriveApp.getFileById(sCache.get('image')).setTrashed(true);
      sCache.remove('image');
    }

    replyTxt(repToken, gemini);
    sCache.put('user', uText.slice(0, 10000));
    sCache.put('model', gemini.slice(0, 10000));

    // 画像メッセージのとき
  } else if (msgType == 'image') {
    let imageId = getImageId4Create(eventData);
    sCache.put('image', imageId);
    replyTxt(repToken, '送信された画像について聞きたいことは何ですか?');
  }
}

/**
 * LINEのトークに送信されたメッセージをGemini Pro Vision APIに渡して回答を得るメソッド
 * @param {String} txt - 送信されたメッセージ
 * @param {String} imageid - 画像のファイルID
 */
function getGeminiProVisionAnswerTxt(txt, imageid) {
  try {
    const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${GEMINI_API}`
      , payload = {
        'contents': [{
          'parts': [{
              'text': txt
            },
            {
              'inlineData': {
                'mimeType': 'image/png',
                'data': Utilities.base64Encode(DriveApp.getFileById(imageid).getBlob().getBytes())
              }
            }]
        }]
      }
      , options = {
        'method': 'post',
        'contentType': 'application/json',
        'payload': JSON.stringify(payload)
      };

    const res = UrlFetchApp.fetch(url, options)
      , resJson = JSON.parse(res.getContentText());

    if (resJson && resJson.candidates && resJson.candidates.length > 0) {
      return resJson.candidates[0].content.parts[0].text;
    } else {
      return '申し訳ございません。お答えできません。';
    }
  } catch (ex) {
    writeLog(ex.toString());
    return '申し訳ございません。Gemini Pro Visionの呼び出しで異常終了しました。';
  }
}

このようにすることでsCache.get('image')がある、つまりは前回の画像保存データがある場合は、入力テキストと前回保存した画像をBase64にエンコードしたうえで、Gemini Pro Visionに渡します

getGeminiProVisionAnswerTxtを呼び出したあとは、

  1. 一時保存した画像を削除
  2. Cacheに設定した画像ファイルIDを削除

することを忘れずに行いましょう。
前者を行わないとGoogleドライブがどんどん逼迫されます。後者を行わないとCacheがクリアされるまで(デフォルトで600秒)、Gemini Pro Visionを利用するモードとなります。

こーすけ先生

しかし…上記のプログラムには考慮漏れがあります


Gemini Pro Visionの仕様を改めて確認すると以下のように記載があります

画像データを使用するプロンプトには、次の制限と要件が適用されます。
・画像とテキストを含むプロンプト全体で最大 4 MB

Gemini Pro Visionプロンプトの画像の要件

Base64にエンコードした場合、元の画像サイズが3.5 MB程度であっても4 MBを超えてしまいます
最近のスマホは非常に画像サイズが大きいため、

return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';

が多発する可能性があります

こーすけ先生

そこで、ImgAppライブラリを利用しましょう!

【STEP3】ImgAppライブラリを活用して、画像データを圧縮する

こーすけ先生

このライブラリはGASが苦手とする画像編集を補完するライブラリです

主に、画像のサイズ取得やリサイズ、トリミング等を行うことができます

追加用スクリプトID
1T03nYHRho6XMWYcaumClcWr6ble65mAT8OLJqRFJ5lukPVogAN2NDl-y

こーすけ先生

ライブラリの追加方法は過去記事を参考にしてね!


使用するメソッドは以下の通りです

スクロールできます
メソッド名使い方
getSizeImgApp.getSize(blob)
doResizeImgApp.doResize(fileId, width)

非常に便利なライブラリではありますが、getSizeの際はblobdoResizeの際はfileIdを指定ってのがちょっとめんどくさいなって思いもありつつ、特性をしっかりを把握して利用しましょう

こーすけ先生

ImgAppライブラリを用いてGemini Pro Visionに渡すデータを編集してみましょう!

function getGeminiProVisionAnswerTxt(txt, imageid) {
  try {  
    let convWidth = ImgApp.getSize(DriveApp.getFileById(imageid).getBlob()).width/5
        , convImage = ImgApp.doResize(imageid, Math.round(convWidth));

    const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${GEMINI_API}`
          , payload = {
              'contents': [{
                'parts': [{
                  'text': txt 
                },
                {
                  'inlineData': {
                    'mimeType': 'image/png',
                    'data': Utilities.base64Encode(convImage.blob.getBytes()) 
                  }
                }]
              }]
            }
          , options = {
              'method': 'post',
              'contentType': 'application/json',
              'payload': JSON.stringify(payload)
            };

    const res = UrlFetchApp.fetch(url, options)
          , resJson = JSON.parse(res.getContentText());

    if (resJson && resJson.candidates && resJson.candidates.length > 0) {
      return resJson.candidates[0].content.parts[0].text;
    } else {
      return '申し訳ございません。お答えできません。';
    }
  } catch (ex) {
    return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';
  }
}

getBlob()は、DriveApp.getFileById(imageid)で取得したファイルに対して.getBlob()を呼び出して、そのファイルのデータをBlobとして取得しています

 let convWidth = ImgApp.getSize(DriveApp.getFileById(imageid).getBlob()).width/5
こーすけ先生

このようにImgAppライブラリを用いて画像のWidthを取得し、ひとまず1/5くらいに修正してみました

これにより、元画像の幅の1/5がconvWidthとして計算されます

ただし、これでもあまりにも大きな元画像である場合は、4 MBを超えることはあると思います
そのため、一律でWidthを1000等に設定し、doResizeのみを使うのもありかもしれません

こーすけ先生

ここは、自分で実際に相談Botを使いながらチューニングしていきましょう

プログラム全文

const GEMINI_API    = '**取得した「Gemini Pro」のAPI keyを記載する**';
const REPLY_URL     = 'https://api.line.me/v2/bot/message/reply';
const LINEAPI_TOKEN = '**LINEで取得したチャネルアクセストークンを記載する**';
const OUT_DIR       = '**一時保存領域のフォルダID**';

const sCache = CacheService.getScriptCache();
/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e){
  // イベントデータはJSON形式となっているため、parseして取得
  const eventData = JSON.parse(e.postData.contents).events[0]
        , repToken = eventData.replyToken
        , msgType = eventData.message.type;
  // テキストメッセージのとき
  if (msgType=='text') {
    let uText = eventData.message.text
        , gemini;
    if (!sCache.get('image')) {
      gemini = getGeminiProAnswerTxt(uText);
    } else {
      gemini = getGeminiProVisionAnswerTxt(uText, sCache.get('image'));
      DriveApp.getFileById(sCache.get('image')).setTrashed(true);
      sCache.remove('image');
    }
    replyTxt(repToken, gemini);
    sCache.put('user', uText.slice(0, 10000));
    sCache.put('model', gemini.slice(0, 10000));

  // 画像メッセージのとき
  } else if (msgType=='image') {
    let imageId = getImageId4Create(eventData);
    sCache.put('image', imageId);
    replyTxt(repToken, '送信された画像について聞きたいことは何ですか?');
  }
}

/**
 * LINEのトークに送信されたメッセージをGemini Pro APIに渡して回答を得るメソッド
 * @param {String} txt - 送信されたメッセージ
 */
function getGeminiProAnswerTxt(txt) {
  let contentsStr = '';
  // キャッシュにuidに紐づく情報が存在した場合、情報には過去の質問文が入っているためそれを取得
  if (sCache.get('user')) {
    contentsStr += `{
      "role": "user",
      "parts": [{
        "text": ${JSON.stringify(sCache.get('user'))}
      }]
    },
    {
      "role": "model",
      "parts": [{
        "text": ${JSON.stringify(sCache.get('model'))}
      }]
    },`
  }
  contentsStr += `{
    "role": "user",
    "parts": [{
      "text": ${JSON.stringify(txt)}
    }]
  }`
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${GEMINI_API}`
        , payload = {
            'contents': JSON.parse(`[${contentsStr}]`)
          }
        , options = {
            'method': 'post',
            'contentType': 'application/json',
            'payload': JSON.stringify(payload)
          };
  const res = UrlFetchApp.fetch(url, options)
        , resJson = JSON.parse(res.getContentText());

  if (resJson && resJson.candidates && resJson.candidates.length > 0) {
    return resJson.candidates[0].content.parts[0].text;
  } else {
    return '回答を取得できませんでした。';
  }
}

/**
 * LINEのトークで送信された画像をGoogleドライブに保存し、ファイルIDを返却するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function getImageId4Create(e) {
  const url = 'https://api-data.line.me/v2/bot/message/' + e.message.id + '/content'
        , options = { 
            'method': 'get',
            'headers': {
              'Authorization': 'Bearer ' + LINEAPI_TOKEN,
            }
          };
  const data = UrlFetchApp.fetch(url, options)
        , imageData = data.getBlob().getAs('image/png').setName(Number(new Date()));
  return DriveApp.getFolderById(OUT_DIR).createFile(imageData).getId();
}

/**
 * LINEのトークに送信されたメッセージをGemini Pro Vision APIに渡して回答を得るメソッド
 * @param {String} txt - 送信されたメッセージ
 */
function getGeminiProVisionAnswerTxt(txt, imageid) {
  try {  
    let convWidth = ImgApp.getSize(DriveApp.getFileById(imageid).getBlob()).width/5
        , convImage = ImgApp.doResize(imageid, Math.round(convWidth));
    const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${GEMINI_API}`
          , payload = {
              'contents': [{
                'parts': [{
                  'text': txt 
                },
                {
                  'inlineData': {
                    'mimeType': 'image/png',
                    'data': Utilities.base64Encode(convImage.blob.getBytes())
                  }
                }]
              }]
            }
          , options = {
              'method': 'post',
              'contentType': 'application/json',
              'payload': JSON.stringify(payload)
            };

    const res = UrlFetchApp.fetch(url, options)
          , resJson = JSON.parse(res.getContentText());

    if (resJson && resJson.candidates && resJson.candidates.length > 0) {
      return resJson.candidates[0].content.parts[0].text;
    } else {
      return '申し訳ございません。お答えできません。';
    }
  } catch (ex) {
    return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';
  }
}

/**
 * LINEのトークにメッセージを返却するメソッド
 * @param {String} token - メッセージ返却用のtoken
 * @param {String} text - 返却テキスト
 */
function replyTxt(token, txt){
  const message = {
                    'replyToken' : token,
                    'messages' : [{
                      'type': 'text',
                      'text': txt
                    }]
                  }
        , options = {
                    'method' : 'post',
                    'headers' : {
                      'Content-Type': 'application/json; charset=UTF-8',
                      'Authorization': 'Bearer ' + LINEAPI_TOKEN,
                    },
                    'payload' : JSON.stringify(message)
                  };
  UrlFetchApp.fetch(REPLY_URL, options);
}
こーすけ先生

プログラムの修正が終わったら、再デプロイしましょう

デプロイ方法を誤るとデプロイされたURLが変わってしまうので、必ずこちらを参考に行ってください
※URLが変わるとLINE Developer側の設定も変更しなければなりません

全3回に渡る「Gemini Pro API」を使ったLINE bot講座はこれにておしまいです!
お疲れさまでした!

こーすけ先生

私が作った「LINE bot」です!
良かったらお友だちになってください!

まとめ

お疲れ様でした
今回は「GAS × Gemini Pro API × LINE Messaging API の相談Botで画像を扱う」ということで、これまで作成した相談Botをさらに進化させ、テキストだけでなく画像も扱えるようになりました

どうでしょう、GASってやばくないですか!?
 ひとまず、今回で「 Gemini Pro API × LINE編」は完結です

こーすけ先生

次回以降はまたGoogleフォームに戻ろうと思います

こーすけ先生

GAS×LINE APIだとログが出力されない!と質問を受けたので、外部サービスと連携する際にログを出力する方法についてまとめました!

ぜひ、皆さんも実業務でのGASの活用に取り組んでみてください
引き続き、GASを楽しんでいきましょう!!

こーすけ先生

X(旧:Twieer)にて、ブログの更新やQiita記事の更新、GAS情報をお届けしますので、是非フォローしてください!

100日後に起業する公務員

こーすけ先生

退職までの漫画をゆるくXにて更新中!
是非フォローしてね!

この記事が気に入ったら
いいね または フォローしてね!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

GASの人。ITベンダSEとして12年勤務し、民間、金融、官公庁の現場を一通り経験済。html、css、JavaScript、Java、PHPも分かります。最近は専らGASで小規模アプリケーションを頻繁に作成しています。GASのことなら何でもお任せあれ!現在は公務員として働きながら、起業に向けて着々と準備中です!

コメント

コメントする

CAPTCHA


目次