見出し画像

ChatGPT(4-vision)を自宅冷蔵庫に連携した話

はじめに


GPT-4が発表された頃(去年春頃)から、画像認識機能が解禁されたらどうしてもやりたいことがありました。

それは、冷蔵庫の中身の写真から献立を作成させるというデモの再現です。

今回は、これを自宅の冷蔵庫に作ってみました。

GPT-4-Visionのモデルを使って、冷蔵庫の上に設置されたカメラを連携し、「LINEで在庫確認」したり、「余っている食材で毎週の献立スケジュールを自動作成」してもらうということを目指していきます。


システムの設計


専門的な内容なので、あまり興味のない方は、ここと次の章を読み飛ばしてください。

まずは今回作成したシステムの構成を紹介します。

簡単に説明したいと思います。

まず、冷蔵庫上部に取り付けたラズパイが冷蔵庫内部の写真を撮影し、ドライブに保存します。

この時、ドライブには冷蔵庫の扉が開かれるたびに画像が更新されるため、常に最新の状態がアップロードされていることになります。

続いて、ユーザー(僕)は作成したLINEの公式アカウントにメッセージを送ると、GoogleAppsScriptがそれを受け取り、メッセージとドライブの画像を使って、ChatGPT(4-vision)と会話を行います。

会話の内容はスプレッドシートに保存しておき(会話履歴のデータベースとして利用)、次以降の会話ではスプレッドシートの会話履歴を参照しながらChatGPTと会話を行っていきます。


なお、Google Apps Scriptのスクリプトは、この記事の最後に添付しています。LINEとDrive、Spread Sheet、ChatGPTを連携するようにしたものです。全然、冷蔵庫以外にも使えるスクリプトだと思うので、ぜひに。

ラズパイのソースコードに関しては、自宅のデジタルツインシステムに連携してしまっている以上、切り分けがややこしいので今回掲載はパスとさせてください。(と言いつつ内容は単純で、ラズパイでのGPIO入力をトリガーにカメラ映像をキャプチャして、ドライブに投げるだけです)

開発うらばなし


我が家の冷蔵庫はスマート家電ではないため、自由に使える扉の開閉センサやカメラはついていません。

そのため、まずは冷蔵庫をスマート家電化するところから始まります。

パーツとしては、家にあった「RaspberryPi 3b+」と、「カメラモジュール V3(NoIR/広角)」、「リードスイッチ(開閉センサ)」を使用しました。

3Dプリンタを使って、カメラをマウントするアームを作成していきます。

もし冷蔵庫にカメラを取り付け予定のある方は、今回設計した3Dプリンタ用STLモデルを置いておきますのでお使いください。(カメラはPicamに加えて、M5cameraも固定できるように設計しています)


開閉センサーは、無線タイプのスマートホーム用製品が市販されていたりしますが、僕の場合は電池交換時期になっても交換をめんどくさがる可能性が高いので、有線タイプを採用しました。

ラズパイでGPIOを常に読み取り状態にしておき、開閉センサーで開かれたことを検知すると、カメラで撮影するようにしています。
この仕組みでドライブにアップロードされていく画像がこちら。

メインの冷蔵室
冷凍室
野菜室

冷凍室と野菜室は小段があるので、全部を映すことは出来ません。
また、冷凍室と野菜室に関しては、画像をトリミングすることもできますが、下の区画であることを分かりやすくなるかとも思い、いったんそのままChatGPTに画像を渡すようにしています。


実際使ってみた使用感


本記事最後のソースコードを週1回の定期実行するようにすれば、以下のような献立を毎週提案してくれるようになります。

基本的には在庫にあるものだけでメニュー作成をしてくれますが、もし買い出しにいける場合を考えて、代替メニューという選択肢も提案に混ぜるようにしています。
*印がついているものが、冷蔵庫内に発見できなかったものです。

カメラの画角がすべてを映しているわけではないということもあり、画像認識の間違いはしばしばあります。が、その時は在庫のものでレシピを調整するように指示を出すことで再考案させていて、今のところ不都合に感じることはありませんでした。


最近で言えば、おせちもこの仕組みを活用して、レシピを作成させましたが、非常に満足のいくものが出来上がりました。

ChatGPTが作成したおせち

実はこのおせちも、「おせち」だということを伝えずに、下の写真だけを見せて再現するレシピを考えさせたものになります。
少し具材の認識間違いがありますが、味は十分だったので問題なし。

再現お題の写真

僕の場合はおせちを作ったことが無かったので、調理方法を聞いたりしつつ、とても良いインストラクターになってくれました。


今後の展望


ひとまず、冷蔵庫の在庫状況を加味しながら、簡単なレシピから難しいレシピの再現までを試してみて、ChatGPTの底力を実際に体感できました。
引き続き、ChatGPTがどこまで尖った指示にまで対応できるのか、試してみるつもりです。

まずは、ChatGPTが冷蔵庫の在庫状況を見ながら栄養管理したり、筋トレメニューを提案してくれる、

「100日後にムキムキにさせるGPT」

というものを新たに開発したので、ただいま性能検証中だったりします。(10年ぶりに筋トレなう。。)


これは、まだ始まったばかりですが、もっと詳しい内容については、過去にYoutubeで紹介しているものがあります

ぜひご参照ください。


GASのスクリプト


最後に、スクリプトの最終完成形がこちらになります。
プロジェクトの設定から、5つのスクリプトプロパティを設定してください。

  • DRIVE_FOLDER_ID
    (冷蔵庫の内部画像を格納するDriveのフォルダID。スクリプト内の「picture_names」の配列内容を適宜変更してください。)

  • LINE_ACCESS_TOKEN
    (作成した公式アカウントのLINEアクセストークン。LINE DeveloperではwebhookにGASのURLを指定する必要があります)

  • OPENAI_APIKEY
    (gpt-4-visionが実行可能な、openaiのapiキー)

  • SHEET_ID
    (スプレッドシートのID)

  • SHEET_NAME
    (スプレッドシートの使用するシートの名前)

function doPost(e=null) {
  const props = PropertiesService.getScriptProperties()

  const model = "gpt-4-vision-preview"
  const ref_log_size = 4    // userとassistant合わせて遡る会話数
  const max_tokens = 4000
  const picture_names = ["main_compartment.png", "freezer_compartment.png", "vege_compartment.png"]
  const user_image_names = "userImage.jpg";

  const systemMessage = [
    '写真は冷蔵庫各室を映しています。',
    '1枚目写真の中央部分はメインの冷蔵室で、早く食べるべき食材全般(惣菜、果物、飲み物、お菓子等)を入れることの多い場所です。',
    '1枚目写真の左ポケット部分にはドレッシング、調味料等を入れることが多いです。',
    '1枚目写真の右ポケット部分には卵や飲み物等を入れることが多いです。',
    '2枚目写真は冷凍室で、主に長期保管用の肉類や炊いた米、うどん等を入れることが多いです。',
    '3枚目写真は野菜室で、主に野菜や飲み物を入れることが多いです。',
    'これらの写真を参照して、Userからの質問や要望に日本語で回答してください。',
  ].join('\n');

  let today = new Date();
  let weekday = ['日', '月', '火', '水', '木', '金', '土'][today.getDay()]

  const defaultMessage = [
    '冷蔵庫に入っているものを使って、本日から一週間分の晩御飯の献立計画を作成してください。',
    'また、足りない食材を揃えると、より優れた献立計画が立てられる場合、そちらも代替案として作成してください。',
    `そして、上記を踏まえて以下のように、応答してください。なお、本日は${weekday}曜日です。`,
    '# 曜日',
    '# メニュー名',
    ' ・ 材料の箇条書き',
    '# 代替メニュー',
    ' ・ 材料の箇条書き(足りないものには*をつける)',
  ].join('\n');

  // Driveから画像を取得
  var items = getBase64ImagesFromDrive(props);
  
  // Lineからのメッセージによって起動したかどうかを確認
  var event = null;
  var userMessage = null;
  var userImage = null;
  var fromLine = (e != null) && !("triggerUid" in e)

  // Lineからのメッセージによって起動した場合はこちらへ
  if (fromLine) {
    var event = JSON.parse(e.postData.contents).events[0];

    var lineMessageType = event.message.type;
    if (lineMessageType == "text")
    {
      var userMessage = event.message.text;
    }
    else if (lineMessageType == "image")
    {
      var messageId = event.message.id;
      var userImage = getLineImage(props, messageId);
      saveBase64ImageToDrive(props, userImage, user_image_names)
      sendLine(props, event, [{'type': 'text', 'text': "この画像について聞きたいことはありますか?",}])
      return;
    }

    // 「画像」と、「消」または「削除」が含まれていたら、Driveの画像を消す
    if (userMessage.indexOf("画像") > -1 && (userMessage.indexOf("消") > -1 || userMessage.indexOf("削除") > -1)) {
      deleteImageFromDrive(props, user_image_names);
      sendLine(props, event, [{'type': 'text', 'text': "送信された画像を除外しました。",}])
      return;
    }
  }

  // 定期実行の場合はこちらへ
  else {
    var userMessage = defaultMessage
  }
  
  // Post to ChatGPT
  response = postChatGPT(props, model, userMessage, systemMessage, items, picture_names, user_image_names, ref_log_size, max_tokens);
  Logger.log(response)

  // Post to Line
  postLine(props, fromLine, event, response, items)
}


function getBase64ImagesFromDrive(props) {

  var folder = DriveApp.getFolderById(props.getProperty('DRIVE_FOLDER_ID'));
  var images = folder.getFiles()

  var items = {}
  while (images.hasNext()) {
    var image = images.next();
    var blob = image.getBlob();
    var b64_image = Utilities.base64Encode(blob.getBytes());

    items[image.getName()] = {
      "b64_image": b64_image,
      "image_url": image.getUrl()
    };
  }
  return items
}


function saveBase64ImageToDrive(props, image, fileName) {
  var folder = DriveApp.getFolderById(props.getProperty('DRIVE_FOLDER_ID'));
  var imageBlob = image.getBlob().setName(fileName);

  var oldImages = folder.getFilesByName(fileName);
  while (oldImages.hasNext()) {
    var image = oldImages.next();
    image.setTrashed(true);
  }

  folder.createFile(imageBlob);
}


function deleteImageFromDrive(props, fileName) {
  var folder = DriveApp.getFolderById(props.getProperty('DRIVE_FOLDER_ID'));

  var oldImages = folder.getFilesByName(fileName);
  while (oldImages.hasNext()) {
    var image = oldImages.next();
    image.setTrashed(true);
  }
}


function postChatGPT(props, model, userMessage, systemMessage, items, picture_names, user_image_names, log_size = 3, max_tokens = 4000) {

  // チャット履歴を読み込み
  var messages = getChatHistory(props, log_size)

  // システムメッセージを添付
  messages.push({
    "role": "system",
    "content":[{
      "type": "text",
      "text": systemMessage
    }]
  })

  // 冷蔵庫画像を添付
  var sysContents = []
  sysContents.push({
      "type": "text",
      "text": systemMessage
  })
  for (var pic_name of picture_names) {
    sysContents.push({
      "type": "image_url",
      "image_url": {
        "url": `data:image/png;base64,${items[pic_name]["b64_image"]}`
      }
    })
  }
  messages.push({
    "role": "system",
    "content": sysContents
  })


  var userContents = []

  // メッセージを添付
  userContents.push({
    "type": "text",
    "text": userMessage
  })
  if (user_image_names in items)
  {
    userContents.push({
      "type": "image_url",
      "image_url": {
        "url": `data:image/jpeg;base64,${items[user_image_names]["b64_image"]}`
      }
    })
  }

  messages.push({
    "role": "user",
    "content": userContents
  })

  const requestOptions = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer "+ props.getProperty('OPENAI_APIKEY')
    },
    "payload": JSON.stringify({
      "model": model,
      "messages": messages,
      "max_tokens": max_tokens
    })
  }
  Logger.log("send___________")
  for (var message of messages)
    Logger.log(message)
  Logger.log("___________")

  const response = UrlFetchApp.fetch("https://api.openai.com/v1/chat/completions", requestOptions)
  Logger.log(response)

  const responseText = response.getContentText();
  const json = JSON.parse(responseText);
  const gptResponse = json['choices'][0]['message']['content'].trim();

  updateChatHistory(props, userMessage, gptResponse)

  return gptResponse;
}


function getChatHistory(props, log_size = 3) {
  var spreadSheet = SpreadsheetApp.openById(props.getProperty('SHEET_ID'));
  var sheet = spreadSheet.getSheetByName(props.getProperty('SHEET_NAME'));

  var lastRow = sheet.getLastRow();
  var startRow = (lastRow - log_size > 0 ? lastRow - log_size : 0) + 1

  var chatHistory = [];
  log_size = Math.min(lastRow - startRow + 1, log_size)
  if (lastRow > 0)
  {
    var table = sheet.getSheetValues(startRow, 1, lastRow, 2);
    Logger.log(table)
    Logger.log(startRow, lastRow)
    for (var i = 0; i < log_size; i++) {
      chatHistory.push({
        "role": table[i][0],
        "content": [{
          "type": "text",
          "text": table[i][1]
        }]
      })
    }
  }
  return chatHistory
}


function updateChatHistory(props, userMessage, gptResponse) {
  var spreadSheet = SpreadsheetApp.openById(props.getProperty('SHEET_ID'));
  var sheet = spreadSheet.getSheetByName(props.getProperty('SHEET_NAME'));
  var lastRow = sheet.getLastRow();

  // 開始行, 開始列, 行数, 列数  開始番号は0でなく1
  sheet.getRange(lastRow + 1, 1, 1, 1).setValues([["user"]])
  sheet.getRange(lastRow + 1, 2, 1, 1).setValues([[userMessage]])
  sheet.getRange(lastRow + 2, 1, 1, 1).setValues([["assistant"]])
  sheet.getRange(lastRow + 2, 2, 1, 1).setValues([[gptResponse]])
}


function postLine(props, fromLine, event, gpt_response, items) {

  var messages = []

  var message_gpt = {
    'type': 'text',
    'text': gpt_response,
  }
  messages.push(message_gpt)

  if (fromLine)
    sendLine(props, event, messages);
  else
    broadcastLine(props, messages);
}


function sendLine(props, event, messages) {
  UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + props.getProperty('LINE_ACCESS_TOKEN'),
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': event.replyToken,
      'messages': messages
    })
  })
}


function broadcastLine(props, messages) {
  UrlFetchApp.fetch('https://api.line.me/v2/bot/message/broadcast', {
    method: 'post',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + props.getProperty('LINE_ACCESS_TOKEN'),
    },
    payload: JSON.stringify({
      'messages': messages
    }),
  });
}


function getLineImage(props, messageId) {
  var URL = "https://api-data.line.me/v2/bot/message/" + messageId + "/content";
  var image = UrlFetchApp.fetch(URL, {
                "headers": {
                  "Content-Type": "application/json; charset=UTF-8",
                  "Authorization": "Bearer " + props.getProperty('LINE_ACCESS_TOKEN'),
                },
                "method": "get"
              });
  return image;
}

この記事が参加している募集

やってみた

この記事が気に入ったらサポートをしてみませんか?