二代目めんツナかんかんIoTを作ってみた(ソラカメとAI画像解析で在庫管理)

こちらの投稿は、SORACOM Advent Calendar 2025 の17日目の投稿になります。

はじめに

以前、SORACOM Advent Calendar 2019 の18日目で、「めんツナかんかんIoTを作ってみた(SORACOM LTE-M Button Plus を使って重量変化を通知)」という内容を投稿しました。
繰り返しになりますが、ストーリは以下になります(笑)

福岡・明太子でおなじみの株式会社ふくや様が、2019年4月1日に発表した「ふくやIoT」。
「うちの明太子はなくならない。」をテーマに打ち出されたこのサービス。「IoT」=「いつも/おいしく/とどく」ということで、重量センサーとSORACOM SIMが内蔵されたケースが、明太子がなくなりそうなタイミングを検知し、利用者の操作不要で新鮮な明太子をピッタリのタイミングで届けてくれるというもの。
ソラコムさん公式ブログ:https://blog.soracom.com/ja-jp/2019/04/05/internetoftarako/
プレスリリース:https://www.atpress.ne.jp/news/180857
サービスは福岡限定のため、私が在住していた岡山では参加資格すらなかったわけです。

そこで、最高に美味しい缶詰(個人の感想です)である株式会社ふくや様が販売されている「めんツナかんかん」がなくなりそうになったら電話で通知してくれる「めんツナかんかんIoT」を作りました。
※私は、非公式/自称めんツナかんかんエバンジェリストです。
この初代「めんツナかんかんIoT」は、重量センサー+マイコン+SORACOM LTE-M Button Plusを使って、SORACOM Funk+AWS Lambda+Twilioで架電するものでした。
初代作ってみたブログ:https://takeshi.furusato.blog/mtc2-iot.html

今回の二代目「めんツナかんかんIoT」は、ソラカメで撮影した画像をAI画像解析、めんツナかんかんの個数をカウントし、在庫数が変動したら通知するというものを作ってみることにしました。

作ってみた

せっかくなので、SORACOM UG Okayamaを開催、イベントのワークショップネタとして持ち込み、作ってみることにしました。
※当日のイベント開催レポートはこちら「【イベント開催報告】SORACOM UG Okayama #6 IoTカメラと生成AIで画像分析ワークショップ」です。

SORACOM Flux アプリ テンプレート を使って簡単作成

今回は「SORACOM Flux」をベースに作ってみました。
SORACOM Fluxは、ローコードでIoTアプリを作れるサービスになります。
このSORACOM Flux、アプリテンプレートが用意されており、これがとても便利です。今回は「ソラカメで人数検知して可視化と通知」というテンプレートを使います。

こちらクリックし、実行間隔・対象ソラカメのデバイスIDを選択、送信先メールアドレスを入れるだけで、一通り動作するものが作成できます(すごい!)

まずは、このテンプレートで正常に動くことを確認します。

SORACOM Flux アプリ テンプレートをカスタマイズ

人数がちゃんとカウントできてメールが届いたら、ここから画像解析のプロンプトをめんツナかんかんを認識するようにカスタマイズします。

現在のプロンプトは

# リクエスト内容 【静止画中の物体検知】
添付された画像について、画像に映り込んでいるすべての人物(部分的に写っている人も含む)および車両(反射や窓越しのものも含む)を数えてください。

# 画像の前提
この画像はオフィス室内(執務エリア)を撮影したものです。

# 物体検知の方針
一部が隠れていたり、鮮明でない人物・車両も、推定されるものはすべてカウント対象としてください。

# 出力フォーマット
結果は必ず以下のようなJSONフォーマットで返してください。

{"person":2, "car":1}

となっています。これを以下のように変更します。

# リクエスト内容 【静止画中の物体検知】
添付された画像について、画像に映り込んでいるすべての缶詰の個数を数えてください。

# 画像の前提
この画像は缶詰を保管しているテーブルを撮影したものです。

# 物体検知の方針
一部が隠れていたり、鮮明でない缶詰も、すべてカウント対象としてください。

# 出力フォーマット
結果は必ず以下のようなJSONフォーマットで返してください。

{"count":3}

という形で、必要最低限の部分を「缶詰」に書き換えます。
またAIが解析して出力した内容を、別チャンネルに送信するためのフォーマットも変更しておきます。
※他にも、Eメール通知内容やデータ保存内容もアウトプットされている項目が変わっているので変更しておきます。

これで缶詰を認識するか確認します。

ちゃんと在庫数を通知することができました!
これで、Fluxのインターバルタイマー(10分間隔)をONにすれば、在庫管理できる!!
と思ったんですが、、、、
10分間隔でずっとメールが届くんですよね。
(メール送信の条件が [count > 0]なので、当然ですね。)

在庫が増減したときだけ通知してほしい

在庫が増減したときだけ通知してほしいのですが、このままだと在庫が変動しなくてもメール通知されてしまいます。
メール通知条件を「1つ前の情報から変更があったら」というようにしたいのですが、Flux上で実現できなさそう。

そこで、外部サービスに在庫数を送信し、変動チェックとメール送信は外部から行うことにしました。
今回は、GoogleスプレッドシートとGoogle App Scriptを使うことにしました。

Google App Scriptには以下のコードを設定する。

/** =========================================
 * 在庫カウント受信 → 変化時のみメール通知(GAS + スプレッドシート)
 * 通知は Gmail(MailApp)で送信。
 *
 * ■シート構成(自動作成)
 *   - Current : A1=product_id, B1=count, C1=image_path, D1=updated_at(1行保持)※B1以外は白文字にしておく
 *   - History : 追記ログ(timestamp, product_id, old_count, new_count, diff, image_path, source_ip)
 *   - Settings: A1=notify_emails(カンマ区切り), A2=shared_secret(トークン)
 *
 * ■GET 仕様(推奨)
 *   /exec?token=YOUR_SECRET&count=123&image_path=...(URLエンコード必須)
 *
 * ■POST 仕様(互換)
 *   { "input":  { "image_path": "..." }, "output": { "count": 123 } }
 * ========================================= */

const TZ = 'Asia/Tokyo';
const CURRENT_SHEET  = 'Current';
const HISTORY_SHEET  = 'History';
const SETTINGS_SHEET = 'Settings';
const DEFAULT_PRODUCT_ID = '****';

const SPREADSHEET_ID = '****************';

function fmt_(d) {
  return Utilities.formatDate(d, TZ, "yyyy-MM-dd'T'HH:mm:ssXXX");
}
function ss_() {
  return SpreadsheetApp.openById(SPREADSHEET_ID);
}

function ensureSheets_() {
  const ss = ss_();

  // Settings
  let settings = ss.getSheetByName(SETTINGS_SHEET);
  if (!settings) {
    settings = ss.insertSheet(SETTINGS_SHEET);
    settings.getRange('A1').setValue('notify_emails(例: [email protected],[email protected])');
    settings.getRange('A2').setValue('YOUR_SECRET');
  }

  // Current
  let cur = ss.getSheetByName(CURRENT_SHEET);
  if (!cur) {
    cur = ss.insertSheet(CURRENT_SHEET);
    cur.getRange('A1:D1').setValues([['product_id','count','image_path','updated_at']]);
  }

  // History
  let hist = ss.getSheetByName(HISTORY_SHEET);
  if (!hist) {
    hist = ss.insertSheet(HISTORY_SHEET);
    hist.getRange(1,1,1,7).setValues([['timestamp','product_id','old_count','new_count','diff','image_path','source_ip']]);
  }
}

function handle_(countNumber, imagePath, sourceIp) {
  ensureSheets_();
  const now = new Date();
  const ss = ss_();

  const lock = LockService.getScriptLock();
  lock.waitLock(10000);

  try {
    const cur = ss.getSheetByName(CURRENT_SHEET);
    const row = cur.getRange('A1:D1').getValues()[0];
    let oldCount = Number(row[1]);
    if (!Number.isFinite(oldCount)) oldCount = NaN;

    const changed = !Number.isFinite(oldCount) || oldCount !== countNumber;

    if (changed) {
      const iso = fmt_(now);

      cur.getRange('A1').setValue(DEFAULT_PRODUCT_ID);
      cur.getRange('B1').setValue(countNumber);
      cur.getRange('C1').setValue(String(imagePath));
      cur.getRange('D1').setValue(iso);

      const hist = ss.getSheetByName(HISTORY_SHEET);
      hist.appendRow([
        iso,
        DEFAULT_PRODUCT_ID,
        Number.isFinite(oldCount) ? oldCount : '',
        countNumber,
        Number.isFinite(oldCount) ? (countNumber - oldCount) : '',
        String(imagePath),
        String(sourceIp || '')
      ]);

      notifyChange_(ss, {
        productId: DEFAULT_PRODUCT_ID,
        oldCount: Number.isFinite(oldCount) ? oldCount : null,
        newCount: countNumber,
        diff: Number.isFinite(oldCount) ? (countNumber - oldCount) : null,
        imagePath: String(imagePath),
        when: iso
      });

      return { status: 200, updated: true, product_id: DEFAULT_PRODUCT_ID, image_path: imagePath, count: countNumber, message: 'count changed and saved' };
    }

    return { status: 200, updated: false, product_id: DEFAULT_PRODUCT_ID, image_path: imagePath, count: countNumber, message: 'no change (count identical)' };

  } catch (err) {
    console.error(err);
    return { status: 500, message: 'internal error' };
  } finally {
    lock.releaseLock();
  }
}

function doGet(e){
  const ss = ss_();
  const settings = ss.getSheetByName(SETTINGS_SHEET);
  const expected = (settings && settings.getRange('A2').getValue() || '').toString().trim();
  const token = (e.parameter && e.parameter.token) || '';
  if (expected && token !== expected) {
    return json_({ status: 401, message: 'unauthorized' });
  }

  const countStr   = (e.parameter && e.parameter.count);
  const imagePath  = (e.parameter && e.parameter.image_path) || '';
  const sourceIp   = (e.parameter && e.parameter.source_ip) || '';

  const countNumber = Number(countStr);
  if (!Number.isFinite(countNumber) || countNumber < 0) {
    return json_({ status: 400, message: 'count must be a non-negative number' });
  }

  const result = handle_(countNumber, imagePath, sourceIp);
  return json_(result);
}

function doPost(e) {
  ensureSheets_();
  const ss = ss_();
  const settings = ss.getSheetByName(SETTINGS_SHEET);
  const expected = (settings && settings.getRange('A2').getValue() || '').toString().trim();
  const token = (e.parameter && e.parameter.token) || '';
  if (expected && token !== expected) {
    return json_({ status: 401, message: 'unauthorized' });
  }

  if (!e.postData || !e.postData.contents) {
    return json_({ status: 400, message: 'empty body' });
  }
  let body;
  try {
    body = JSON.parse(e.postData.contents);
  } catch (err) {
    return json_({ status: 400, message: 'invalid json' });
  }

  const imagePath = (body && body.input && body.input.image_path) || '';
  const rawCount  = (body && body.output && body.output.count);
  const countNumber = Number(rawCount);
  if (!Number.isFinite(countNumber) || countNumber < 0) {
    return json_({ status: 400, message: 'output.count must be >= 0' });
  }

  const sourceIp = (e.parameter && e.parameter.source_ip) || '';
  const result = handle_(countNumber, imagePath, sourceIp);
  return json_(result);
}

function notifyChange_(ss, p) {
  const settings = ss.getSheetByName(SETTINGS_SHEET);
  const emailsRaw = (settings && settings.getRange('A1').getValue() || '').toString();
  const recipients = emailsRaw.split(',').map(function(s){ return s.trim(); }).filter(function(s){ return !!s; });
  if (recipients.length === 0) return;

  var subject;
  if (p.diff === null) {
    subject = '在庫カウント変更: (初回保存)(現在 ' + p.newCount + ')';
  } else {
    subject = '在庫カウント変更: ' + (p.diff > 0 ? '+' + p.diff : String(p.diff)) + '(現在 ' + p.newCount + ')';
  }

  var lines = [
    'product_id: ' + p.productId,
    p.imagePath ? ('image_path: ' + p.imagePath) : '',
    '旧: ' + (p.oldCount !== null ? p.oldCount : '(初回/不明)'),
    '新: ' + p.newCount,
    '差分: ' + (p.diff !== null ? (p.diff > 0 ? '+' + p.diff : String(p.diff)) : '(初回/不明)'),
    '時刻: ' + p.when + ' (' + TZ + ')'
  ].filter(function(s){ return !!s; });

  var html = lines.join('<br>');
  sendViaGmail_(recipients, subject, html);
}

function sendViaGmail_(toList, subject, htmlBody) {
  if (!toList || toList.length === 0) return;
  MailApp.sendEmail({
    to: toList.join(','),
    subject: subject,
    htmlBody: htmlBody
  });
}

function json_(obj) {
  var out = ContentService.createTextOutput(JSON.stringify(obj));
  out.setMimeType(ContentService.MimeType.JSON);
  return out;
}

これを「ウェブアプリ」としてデプロイし、そのウェブアプリURLをメモしておきます。

AIが検出したアウトプットを接続する先としてWebhookを追加し、上記のウェブアプリURLを追加します。

Webhookの設定は以下の通り
HTTPメソッド:POST
URL:ウェブアプリURLの後に「?token=YOUR_SECRET」をつけたもの
認証方法:なし
HTTPヘッダー:なし
HTTPボディ:「元データのまま転送」

そうすることで、在庫が変動したときだけ、メール通知されるようになります。

まとめ

今回は「二代目めんツナかんかんIoT」って言いたいだけで作ってみましたが、SORACOM Flux アプリテンプレート「ソラカメで人数検知して可視化と通知」のお陰でめっちゃ簡単に作成できました。テンプレートをベースにちょっとカスタマイズするだけで、いろんなものを検出できるのもいいですね。

この記事を書いた人

takeshi.furusato

AWS Samurai 2022
JAWS-UG Okayama
OkayamaWordPressMeetup
JP_Stripes Okayama
SORACOM UG Okayama
TwilioJP-UG Okayama
自称めんツナかんかんエバンジェリスト(https://mtc2.furusato.blog/)
DIGITALJET inc.