Spresenseを活用したAI冷蔵庫
AI 冷蔵庫
概要
私たちは、「SPRESENSE」を用いて、どこにいても冷蔵庫内の食材をチェックできるシステムのプロトを開発しました。このシステムでは、冷蔵庫内をカメラで撮影し、その画像をサーバーに保存します。ユーザーはどこからでもこれらの画像を閲覧でき、現在の食材のストックを一目で確認できます。
また、このシステムは画像認識機能を備えた対話モデル、GPT-4Vを利用しています。これにより、ユーザーは冷蔵庫の中身を基にした夕食の提案を受けたり、何を作れるかについてアドバイスをもらったりすることができます。この機能は、ユーザーが直接画像を見ることなく、冷蔵庫にある食材から最適な献立を提案します。
公開サイト
URL: https://ai-refrigerator.vercel.app/
利用にはGithubアカウント、GPT4Vが利用可能なChatGPT APIKeyが必要です
使い方
- (Web)ログインする
Github アカウントを使ってログインします
OpenAIのAPIKeyはこのリンクより取得可能です
-
(Web)Spresenseの画像アップロード用APIキーを発行する
左上にあるユーザー名をクリックするとAPIKeyというオプションがあるのでクリックします
-
(Web)RegenerateをクリックしてAPIKeyを発行する
- (Spresense)Spresenseのconfig.hを編集して書き込み
編集すべき変数は以下の3つです。
HTTP_AUTH_KEYにStep4で作成したAPIキーを入力してください
#define AP_SSID "your-ssid"
#define PASSPHRASE "your-password"
#define HTTP_AUTH_KEY "Bearer <YOUR API KEY>"
-
(Spresense)動かして画像アップロード
実行!! -
画像を選択してチャットする
チャット欄の左側に+ボタンがあり、クリックすると画像選択が可能です。
試験用にPCから画像アップロードも可能となっております。
(Vercel Blobの制約のため100kB程度の画像を送っていただけると助かります)
システムアーキテクチャ
このシステムは以下のようなアーキテクチャ構成になっています。
使用した部品・サービス
- SPRESENSEメインボード
- SPRESENSEの基礎となるボードです。
- SPRESENSE拡張ボード
- SDカードの挿入や音声のI/Oができるようになる拡張アタッチメント
- SPRESENSE HDRカメラボード
- SPRESENSEメインボードにつなぐことで撮影ができるようになる拡張アタッチメント
- SPRESENSE Wi-Fi Add-onボード (GS2200-WiFi)
- SPRESENSEメインボードにつなぐことでWifi通信ができるようになる拡張アタッチメント
- SDカード
- SPRESENSE拡張ボードに挿入することでプログラムで使用するデータを格納することができる
- React + Next.js
- Web App作成のため
- Vercel + Vercel KV + Vercel Blob
- 作成したWebAppのホスティングや、データ保管のため
- PLAフィラメント
- SPRESENSEを固定するためのケースを出力するための素材
開発に使用したツール
- Arduino IDE
- SPRESENSE上で動作するプログラムのコーディング&SPRESENSEへのインストール
- Sindoh 3DWOX 2X 3D Printer
- モデリングしたケースを出力するための3D Printer
- Fusion360
- ケースをモデリングするためのエディタ
コーディング
Spresense
GS2200 WiFi Addonボードを用いた画像アップロード
公式より提供されているArduinoライブラリをもちいてPOST通信を使いサーバーに画像をアップロードしています。
初めはなかなかうまくいかなかったのですが、ライブラリを眺めているとstrlen(body)
では画像バイナリは正しく送信するサイズの計算が行えないことに気づきました。
src/HttpGs2200.cpp
bool HttpGs2200::post(const char* url_path, const char* body) {
bool result = false;
HTTP_DEBUG("POST Start");
result = connect();
WiFi_InitESCBuffer();
HTTP_DEBUG("Socket Open");
result = send(HTTP_METHOD_POST, 10, url_path, body, strlen(body));
return result;
}
そのため、SDカードから画像を読み込んで画像をアップロードする際にはPOST関数を使用せず、POST関数と同等な関数かつファイルサイズを渡すことが可能な関数をcustom_post
関数として定義することで問題の回避を行いました。
upload.ino
/* ---------------------------------------------------------------------
* Function to send byte data to the HTTP server
* ----------------------------------------------------------------------
*/
bool custom_post(const char *url_path, const char *body, uint32_t size) {
char size_string[10];
snprintf(size_string, sizeof(size_string), "%d", size);
theHttpGs2200.config(HTTP_HEADER_CONTENT_LENGTH, size_string);
Serial.println("Size");
Serial.println(size_string);
bool result = false;
result = theHttpGs2200.connect();
WiFi_InitESCBuffer();
result = theHttpGs2200.send(HTTP_METHOD_POST, 10, url_path, body, size);
return result;
}
void uploadImage(char *filename) {
// Create body
File file = theSD.open(filename, FILE_READ);
// Calculate size in byte
uint32_t file_size = file.size();
// Define_a body pointer having the continuous memory space with size `file_size`
char *body = (char *)malloc(file_size);
if (body == NULL) {
Serial.println("No free memory");
}
// Read byte of the file iteratively and put it in the address where each member of body pointer points out
int index = 0;
while (file.available()) {
body[index++] = file.read();
}
file.close();
// Send the body data to the server
bool result = custom_post(HTTP_POST_PATH, body, file_size);
if (false == result) {
Serial.println("Post Failed");
}
free(body);
}
なお余談ですが、一般的にWebで利用されるmultipart/form-data
を使用した画像アップロードに挑戦していたのですが、まったくうまくいかなかったので、成功した方は教えていただきたいです。
また、アップロード先にVercelにてホスティングしているサーバーを指定し、HTTPSにてアップロードを行うことに挑戦しましたが、ルート証明書を入れても正しくアップロードできませんでした。RSA4096bitで証明書のサイズが大きいこと、リダイレクトがかかることが原因だと考えていますが、GS2200ライブラリ側で問題になってそうなところを修正しても改善しませんでした。
そのため回避策としてVercelのHTTPSのサーバーに対してプロキシするHTTPサーバを立てることで回避しました。
もちろん何もセキュリティ的に良くない構成となっているため、修正したいのですがどうにもできず、改善できた方は教えていただきたいです。
ちなみにプロキシは以下のようなnginx設定を用いました。
server {
server_name ai-refrigerator.konikoni428.com;
listen 80;
listen [::]:80;
charset UTF-8;
location / {
proxy_pass_request_headers off;
proxy_set_header Host ai-refrigerator.vercel.app;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass https://ai-refrigerator.vercel.app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_buffering off;
chunked_transfer_encoding off;
client_max_body_size 10m;
}
}
Web APP
チャット画面には
- React
- Next.js
を用いて開発を行いました。
またWebサービスのホスティングのため、Nextjsの開発元であるVercel社のホスティングサービスを利用しました
比較的無料で使える枠が大きいので今回のようなサービスを公開するには非常に利用しやすいです。
また、ChatGPTのような画面を作るにあたってai-chatbotというNext.jsの開発元であるVercel社が出しているサンプルをベースに開発を行いました。
すべての説明はコード量から難しいため、主にSpresenseから画像を受け取る所に関係するコードについて紹介いたします。
まず、このAI 冷蔵庫 WebAppはログイン機能が存在します。そのため画像アップロード時には誰が画像をアップロードしたのか識別するためにAPIKeyを用いた認証を行います。
APIを発行する処理は以下のコードによって行われます。
registerApiKey()
を呼び出すことでAPIKeyが発行されます。
本WebAppはGithubを用いたログイン連携が可能となっており、その処理はNextAuth.jsによって行われます。
その際、Vercel KVと呼ばれるRedis互換のNoSQLサービスを利用してAPIKeyとuserIdの関連付けデータをサーバー側に保管します。
app/actions.ts
import { kv } from '@vercel/kv'
import { auth } from '@/auth'
const generateRandomString = (length: number) => {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let randomString = '';
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
randomString += characters.charAt(randomIndex);
}
return randomString;
};
export async function registerApiKey() {
const session = await auth()
const userId = session?.user.id
if (!userId) {
return {
error: 'Unauthorized'
}
}
try{
// remove old key
const oldApiKey = await kv.get<string>(`user:apiKey:${userId}`);
await kv.set(`apiKey:${oldApiKey}`, "");
} catch (error) {
console.log("No old api key")
}
const newApiKey = generateRandomString(16)
try {
await kv.set(`user:apiKey:${userId}`, newApiKey);
await kv.set(`apiKey:${newApiKey}`, userId);
return {
apiKey: newApiKey
}
} catch (error) {
// Handle errors
return {
error: "Register Failed"
}
}
}
ここからは実際のアップロードの処理となっています。
Next.jsにはAppRoutingと呼ばれる機能があり、appフォルダ以下のフォルダ構成がそのままAPIに変換され、この例では https://<example.com>/api/uploadに対して、POSTのリクエストを処理するコードになっています。
リクエストが届くとまず初めに認証が行われ、Authorization: Bearer <API KEY>
のようにAuthorizationヘッダに先ほど作成したAPIKeyを付与することで認証が行われます。
問題なく認証が行われると、リクエストボディから画像を取り出し、Vercel Blobというストレージサービスに保管しています。
api/upload/route.ts
import { kv } from '@vercel/kv'
import { put } from '@vercel/blob';
export const runtime = 'edge'
const generateRandomString = (length: number) => {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let randomString = '';
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
randomString += characters.charAt(randomIndex);
}
return randomString;
};
export async function POST(req: Request) {
const authorizationHeader = req.headers.get('Authorization')
if (!authorizationHeader || !authorizationHeader.startsWith('Bearer ')) {
return new Response('Bad request. You need to set Authorization header with Bearer token', {
status: 400,
})
}
const apiKey = authorizationHeader.split('Bearer ')[1]
const userId = await kv.get<string>(`apiKey:${apiKey}`)
if (!userId || userId.length === 0) {
return new Response('Bad api key', {
status: 401
})
}
const imageData = req.body
if (!imageData) {
return new Response('Bad request', {
status: 400
})
}
const date = new Date()
const filename = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}_${generateRandomString(8)}.jpg`;
const blob = await put(`${userId}/${filename}`, imageData, {
access: 'public',
});
return new Response('Success', {
status: 200
})
}
モデリング
より冷蔵庫内の食材を正確に把握できるように、各環境(冷蔵庫)に合わせて、設営場所・カメラ角度を変えれるようなケースをモデリングしました。
角度調整部はこちらのボールジョイントを、SPRESENSE固定部は公式githubをを参考にさせていただきました。
(参考にした角度調整部(左)参考にしたSPRESENSE固定部(右))
モデリングにはFusion360を、プリントにはSindoh 3DWOX 2X 3D Printerを使用しました。
実際のモデルデータはgithubに置いています。
(制作した土台部分のモデル(左)制作したSPRESENSE固定部(右))
(ボール部分によって角度調整が可能(左・中央)。実際にSPRESENSEをマウントして撮影している様子(右))
はじめてのモデリングでジョイント部分がちゃんと機能するか心配でしたが、きれいに出力&機能されました!
全てのソースコード
Spresenseのコード
Spresense_AI_Refrigerator.ino
#include <HttpGs2200.h>
#include <GS2200Hal.h>
#include <SDHCI.h>
// #include <RTC.h>
#include <TelitWiFi.h>
#include <stdio.h> /* for sprintf */
#include <Camera.h>
#include "config.h"
#define CONSOLE_BAUDRATE 115200
#define TOTAL_PICTURE_COUNT 10
#define PICTURE_INTERVAL 1
const uint16_t RECEIVE_PACKET_SIZE = 1500;
uint8_t Receive_Data[RECEIVE_PACKET_SIZE] = { 0 };
TelitWiFi gs2200;
TWIFI_Params gsparams;
HttpGs2200 theHttpGs2200(&gs2200);
HTTPGS2200_HostParams hostParams;
SDClass theSD;
int take_picture_count = 0;
void parse_httpresponse(char *message) {
char *p;
if ((p = strstr(message, "200 OK\r\n")) != NULL) {
ConsolePrintf("Response : %s\r\n", p + 8);
}
}
/* ---------------------------------------------------------------------
* Function to print error message from the camera
* ----------------------------------------------------------------------
*/
void printError(enum CamErr err) {
Serial.print("Error: ");
switch (err) {
case CAM_ERR_NO_DEVICE:
Serial.println("No Device");
break;
case CAM_ERR_ILLEGAL_DEVERR:
Serial.println("Illegal device error");
break;
case CAM_ERR_ALREADY_INITIALIZED:
Serial.println("Already initialized");
break;
case CAM_ERR_NOT_INITIALIZED:
Serial.println("Not initialized");
break;
case CAM_ERR_NOT_STILL_INITIALIZED:
Serial.println("Still picture not initialized");
break;
case CAM_ERR_CANT_CREATE_THREAD:
Serial.println("Failed to create thread");
break;
case CAM_ERR_INVALID_PARAM:
Serial.println("Invalid parameter");
break;
case CAM_ERR_NO_MEMORY:
Serial.println("No memory");
break;
case CAM_ERR_USR_INUSED:
Serial.println("Buffer already in use");
break;
case CAM_ERR_NOT_PERMITTED:
Serial.println("Operation not permitted");
break;
default:
break;
}
}
/* ---------------------------------------------------------------------
* Callback from Camera library when video frame is captured.
* ----------------------------------------------------------------------
*/
void CamCB(CamImage img) {
/* Check the img instance is available or not. */
if (img.isAvailable()) {
/* If you want RGB565 data, convert image data format to RGB565 */
img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565);
/* You can use image data directly by using getImgSize() and getImgBuff().
* for displaying image to a display, etc. */
Serial.print("Image data size = ");
Serial.print(img.getImgSize(), DEC);
Serial.print(" , ");
Serial.print("buff addr = ");
Serial.print((unsigned long)img.getImgBuff(), HEX);
Serial.println("");
} else {
Serial.println("Failed to get video stream image");
}
}
/* ---------------------------------------------------------------------
* Function to send a file in the SD card to the HTTP server
* ----------------------------------------------------------------------
*/
void uploadImage(char *filename) {
// Create body
File file = theSD.open(filename, FILE_READ);
// Calculate size in byte
uint32_t file_size = file.size();
// Define_a body pointer having the continuous memory space with size `file_size`
char *body = (char *)malloc(file_size); // +1 is null char
if (body == NULL) {
Serial.println("No free memory");
}
// Read byte of the file iteratively and put it in the address where each member of body pointer points out
int index = 0;
while (file.available()) {
body[index++] = file.read();
}
file.close();
// Send the body data to the server
bool result = custom_post(HTTP_POST_PATH, body, file_size);
if (false == result) {
Serial.println("Post Failed");
}
free(body);
}
/* ---------------------------------------------------------------------
* Setup Function
* ----------------------------------------------------------------------
*/
void setup() {
/* Open serial communications and wait for port to open */
Serial.begin(CONSOLE_BAUDRATE);
while (!Serial) {
; /* wait for serial port to connect. Needed for native USB port only */
}
/* Initialize SD */
while (!theSD.begin()) {
/* wait until SD card is mounted. */
Serial.println("Insert SD card.");
}
/* -----------------------------------
* GS2200-WiFi Setup
* -----------------------------------
*/
// RTC.begin();
// RtcTime compiledDateTime(__DATE__, __TIME__);
// RTC.setTime(compiledDateTime);
/* initialize digital pin LED_BUILTIN as an output. */
pinMode(LED0, OUTPUT);
digitalWrite(LED0, LOW); // turn the LED off (LOW is the voltage level)
Serial.begin(CONSOLE_BAUDRATE); // talk to PC
/* Initialize SPI access of GS2200 */
Init_GS2200_SPI_type(iS110B_TypeC);
/* Initialize AT Command Library Buffer */
gsparams.mode = ATCMD_MODE_STATION;
gsparams.psave = ATCMD_PSAVE_DEFAULT;
if (gs2200.begin(gsparams)) {
Serial.println("GS2200 Initilization Fails");
while (1)
;
}
/* GS2200 Association to AP */
if (gs2200.activate_station(AP_SSID, PASSPHRASE)) {
Serial.println("Association Fails");
while (1)
;
}
hostParams.host = (char *)HTTP_SRVR_IP;
hostParams.port = (char *)HTTP_PORT;
theHttpGs2200.begin(&hostParams);
Serial.println("Start HTTP Client");
// Serial.println("Start HTTP Secure Client");
/* Set HTTP Headers */
theHttpGs2200.config(HTTP_HEADER_AUTHORIZATION, HTTP_AUTH_KEY);
// theHttpGs2200.config(HTTP_HEADER_TRANSFER_ENCODING, "chunked");
theHttpGs2200.config(HTTP_HEADER_HOST, HTTP_SRVR_IP);
theHttpGs2200.config(HTTP_HEADER_CONTENT_TYPE, "application/octet-stream");
// // Set certifications via a file on the SD card before connecting to the server
// File rootCertsFile = theSD.open(ROOTCA_FILE, FILE_READ);
// // Serial.println(rootCertsFile.available());
// char time_string[128];
// RtcTime rtc = RTC.getTime();
// snprintf(time_string, sizeof(time_string), "%02d/%02d/%04d,%02d:%02d:%02d", rtc.day(), rtc.month(), rtc.year(), rtc.hour(), rtc.minute(), rtc.second());
// theHttpGs2200.set_cert((char*)"TLS_CA", time_string, 0, 1, &rootCertsFile);
// rootCertsFile.close();
/* -----------------------------------
* Camera Setup
* -----------------------------------
*/
CamErr err;
/* begin() without parameters means that
* number of buffers = 1, 30FPS, QVGA, YUV 4:2:2 format */
Serial.println("Prepare camera");
err = theCamera.begin();
if (err != CAM_ERR_SUCCESS) {
printError(err);
}
/* Start video stream.
* If received video stream data from camera device,
* camera library call CamCB.
*/
Serial.println("Start streaming");
err = theCamera.startStreaming(true, CamCB);
if (err != CAM_ERR_SUCCESS) {
printError(err);
}
/* Auto white balance configuration */
Serial.println("Set Auto white balance parameter");
err = theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_DAYLIGHT);
if (err != CAM_ERR_SUCCESS) {
printError(err);
}
/* Set parameters about still picture.
* In the following case, QUADVGA and JPEG.
*/
Serial.println("Set still picture format");
err = theCamera.setStillPictureImageFormat(
// CAM_IMGSIZE_QUADVGA_H,
// CAM_IMGSIZE_QUADVGA_V,
CAM_IMGSIZE_VGA_H,
CAM_IMGSIZE_VGA_V,
CAM_IMAGE_PIX_FMT_JPG);
if (err != CAM_ERR_SUCCESS) {
printError(err);
}
digitalWrite(LED0, HIGH); // turn on LED
}
/* ---------------------------------------------------------------------
* Function to send byte data to the HTTP server
* ----------------------------------------------------------------------
*/
bool custom_post(const char *url_path, const char *body, uint32_t size) {
char size_string[10];
snprintf(size_string, sizeof(size_string), "%d", size);
theHttpGs2200.config(HTTP_HEADER_CONTENT_LENGTH, size_string);
Serial.println("Size");
Serial.println(size_string);
bool result = false;
result = theHttpGs2200.connect();
WiFi_InitESCBuffer();
result = theHttpGs2200.send(HTTP_METHOD_POST, 10, url_path, body, size);
return result;
}
bool first = false;
// the loop function runs over and over again forever
void loop() {
sleep(PICTURE_INTERVAL); /* wait for predefined seconds to take still picture. */
if (!first) {
// first = true;
if (take_picture_count < TOTAL_PICTURE_COUNT) {
/* Take still picture.
* Unlike video stream(startStreaming) , this API wait to receive image data
* from camera device.
*/
Serial.println("call takePicture()");
CamImage img = theCamera.takePicture();
/* Check availability of the img instance. */
/* If any errors occur, the img is not available. */
if (img.isAvailable()) {
/* Create file name */
char filename[16] = { 0 };
sprintf(filename, "PICT%03d.JPG", take_picture_count);
Serial.print("Save taken picture as ");
Serial.print(filename);
Serial.println("");
/* FOR DEBUG
* Remove the old file with the same file name as new created file,
* and create new file.
*/
theSD.remove(filename);
File myFile = theSD.open(filename, FILE_WRITE);
myFile.write(img.getImgBuff(), img.getImgSize());
myFile.close();
uploadImage(filename);
bool result = false;
do {
result = theHttpGs2200.receive(5000);
if (result) {
theHttpGs2200.read_data(Receive_Data, RECEIVE_PACKET_SIZE);
ConsolePrintf("%s", (char *)(Receive_Data));
} else {
// AT+HTTPSEND command is done
Serial.println("\r\n");
}
} while (result);
result = theHttpGs2200.end();
} else {
/* The size of a picture may exceed the allocated memory size.
* Then, allocate the larger memory size and/or decrease the size of a picture.
* [How to allocate the larger memory]
* - Decrease jpgbufsize_divisor specified by setStillPictureImageFormat()
* - Increase the Memory size from Arduino IDE tools Menu
* [How to decrease the size of a picture]
* - Decrease the JPEG quality by setJPEGQuality()
*/
Serial.println("Failed to take picture");
}
} else if (take_picture_count == TOTAL_PICTURE_COUNT) {
Serial.println("End.");
theCamera.end();
}
take_picture_count++;
}
}
Web Appを含む全てのコード
かなりの量なので以下のリポジトリに全てのコードを格納しております
https://github.com/konikoni428/ai_refrigerator
WebAppのデプロイ方法
https://github.com/konikoni428/ai_refrigerator/blob/main/WEB_SETUP.md
-
konikoni428
さんが
2024/01/31
に
編集
をしました。
(メッセージ: 初版)
-
konikoni428
さんが
2024/01/31
に
編集
をしました。
ログインしてコメントを投稿する