🔥

Flutter × Edge AI × IoT で「クラウド非接続」在庫管理システムを構築する #2

に公開
2025/12/25

はじめに

前回の記事では、
・ Flutter × Edge AI × IoT を組み合わせて、クラウド非接続でも動作する
・在庫管理(カウント系)システムを構築するための全体アーキテクチャ
を、現場 DX を想定した構成例として紹介しました。

今回はその続きとして、
・ USB カメラ → Edge AI(TensorFlow Lite)→ Flutter アプリ
・という最小ループを「完全ローカル」で動かすところまで
を、実装イメージ付きで整理します。

実運用で使うコードは案件ごとに変わりますが、

  • どのレイヤで何を担当させるか
  • どこからどこまでを「HTTP API」として切り出すか
  • Flutter アプリ側はどのように結果を受け取るか
    といった設計の方針は共通化できます。

この記事は、以下のような方を想定しています。

  • 現場向けの画像認識・在庫カウントを PoC したいエンジニア
  • 「クラウドは使えないが、AI でカウントや検品をしたい」現場 DX 担当者
  • Flutter を既に業務アプリで使っており、Edge AI との連携を検討している方

全体像のおさらい:今回フォーカスする部分

前回記事では、次のようなレイヤ構成を紹介しました。
・入力レイヤ

  • USB カメラ/産業用カメラ
  • 距離センサ・QR コードリーダ等(必要に応じて)
    ・推論レイヤ(Edge AI)
  • Raspberry Pi / x86 PC / NPU 搭載ボード
  • TensorFlow Lite / ONNX Runtime
    ・プリケーションレイヤ(UI)
  • Flutter (Windows / Web / Linux など)
  • 結果表示・在庫数管理・設定画面
    ・データレイヤ
  • ローカル DB(SQLite 等)
  • CSV / JSON エクスポート
    ・ネットワーク
  • ローカル LAN or スタンドアロン
  • インターネット非接続前提

今回はこのうち、もっとも小さな単位である
・「USB カメラから撮像」→「Edge 側で推論」→「Flutter から HTTP で呼び出し」
という部分にフォーカスします。

在庫 DB や複数ライン展開、ログ設計などは後続の記事で扱い、
まずは「動く最小構成」をクリアすることを目的にします。

開発環境と前提条件

この記事では、具体的な環境の一例として次のような構成を想定します。

  • OS:Windows 10 / 11(Linux でもほぼ同様)
  • 言語 / ランタイム
     - Python 3.x
     - TensorFlow Lite Runtime
     - Flutter 3.x(stable)
  • ハードウェア
    • UVC 対応 USB カメラ 1 台
    • 開発用 PC(Edge デバイスを兼ねる)

ディレクトリ構成はシンプルに、次のように分けておきます。

project-root/
  edge/       # Python + TFLite の推論サーバ
  app/        # Flutter アプリ

前提として、以下は既にできる読者を想定します。
・Flutter プロジェクトの作成や、基本的な画面開発
・Python スクリプトの実行
・pip などによる必要パッケージのインストール

Step 1: 学習済みモデルを TFLite に用意する
本番環境では、学習データの収集やモデル設計・学習が大きなテーマになりますが、
この記事では焦点を「連携方法」に絞るため、次の前提を置きます。
・既に画像認識モデルが学習済みである
・例:棚の箱をカウントする物体検出モデル
・例:OK / NG を判定する画像分類モデル
・そのモデルを TensorFlow Lite 形式(.tflite)に変換済みである
TFLite 形式のファイルが 1 つあれば、
Edge デバイス側では次のような形で配置しておきます。

edge/
  model/
    inventory_model.tflite
  server.py
  camera_utils.py
  inference.py

変換手順自体(SavedModel / Keras → TFLite Converter)は別記事で整理する前提とし、
ここでは「model/inventory_model.tflite が存在する」状態から進めます。

Step 2: Python + TFLite で「ローカル推論サーバ」を立てる
ここからは Edge 側の役割です。
・USB カメラから画像を 1 枚取得
・前処理を行いモデルに入力
・推論結果(個数等)を JSON にまとめ、HTTP 経由で返す
という流れを、Python スクリプトとして実装します。

2-1. USB カメラからの画像取得
まずは USB カメラから 1 フレーム取得するヘルパー関数を用意します。
わかりやすさ重視で、ここでは OpenCV を利用します。

edge/camera_utils.py のイメージ:

import cv2
def capture_frame(device_index: int = 0):
    cap = cv2.VideoCapture(device_index)
    if not cap.isOpened():
        raise RuntimeError("Camera device not opened")
    try:
        # 必要なら解像度設定
        # cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
        # cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
        ret, frame = cap.read()
        if not ret:
            raise RuntimeError("Failed to capture frame")
        return frame
    finally:
        cap.release()

実運用では、毎回オープン/クローズだとオーバーヘッドが大きいので、
カメラを常時オープンしておく実装にすることが多いです。
ここでは「仕組み理解」を優先し、シンプルな形にしています。

2-2. 前処理と TFLite 推論
続いて、TFLite モデルを使って画像から結果を出す関数を用意します。
edge/inference.py のイメージ:

import numpy as np
import cv2
import tflite_runtime.interpreter as tflite
class InventoryModel:
    def __init__(self, model_path: str):
        self.interpreter = tflite.Interpreter(model_path=model_path)
        self.interpreter.allocate_tensors()
        self.input_details = self.interpreter.get_input_details()
        self.output_details = self.interpreter.get_output_details()
    def preprocess(self, frame):
        # 入力サイズにリサイズ
        h, w = self.input_details[0]["shape"][1:3]
        img = cv2.resize(frame, (w, h))
        # モデルに応じて正規化等を実施
        img = img.astype("float32") / 255.0
        img = np.expand_dims(img, axis=0)
        return img
    def infer(self, frame):
        input_data = self.preprocess(frame)
        self.interpreter.set_tensor(
            self.input_details[0]["index"], input_data
        )
        self.interpreter.invoke()
        output_data = self.interpreter.get_tensor(
            self.output_details[0]["index"]
        )
        # ここでは「個数」を返す想定とし、実際のモデルに合わせて変換する
        count = int(output_data[0][0])
        return {"count": count}

実際には、物体検出モデルならバウンディングボックスやスコアを返すなど、
後処理はモデルごとに変わります。
記事としては、処理の流れ(前処理→推論→後処理→辞書化)が分かれば十分です。

2-3. HTTP API で結果を提供(FastAPI など)
最後に、推論処理を HTTP API として公開します。
ここでは FastAPI を例にしますが、Flask でも同じ考え方です。

edge/server.py のイメージ:

from datetime import datetime
from fastapi import FastAPI
from fastapi.responses import JSONResponse
import uvicorn
from camera_utils import capture_frame
from inference import InventoryModel
app = FastAPI()
model = InventoryModel("model/inventory_model.tflite")
@app.get("/health")
def health():
    return {"status": "ok", "time": datetime.now().isoformat()}
@app.get("/predict")
def predict():
    try:
        frame = capture_frame()
        result = model.infer(frame)
        return {
            "count": result["count"],
            "timestamp": datetime.now().isoformat(),
        }
    except Exception as e:
        # 本番ではログ出力なども行う
        return JSONResponse(
            status_code=500,
            content={"error": str(e)},
        )
if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

このサーバを起動すると、
GET http://127.0.0.1:8000/health
GET http://127.0.0.1:8000/predict
といったエンドポイントがローカル PC 上で利用できるようになります。

ここまでで、
Edge 側が「USB カメラ付きのローカル推論サーバ」として振る舞う
状態が整いました。

Step 3: Flutter からローカル HTTP にアクセスする
次は、Flutter アプリ側からこのローカル API を呼び出し、
結果を UI 上に表示する部分です。

3-1. HTTP クライアントの用意
Flutter プロジェクト(app/)を作成し、
pubspec.yaml に HTTP クライアントライブラリを追加します。

ependencies:
  flutter:
    sdk: flutter
  http: ^1.2.0  # バージョンは適宜

シンプルな API クライアントを 1 ファイルにまとめておきます。
lib/api_client.dart のイメージ:

import 'dart:convert';
import 'package:http/http.dart' as http;
class EdgeApiClient {
  EdgeApiClient({this.baseUrl = 'http://127.0.0.1:8000'});
  final String baseUrl;
  Future<Map<String, dynamic>> health() async {
    final url = Uri.parse('$baseUrl/health');
    final res = await http.get(url).timeout(const Duration(seconds: 3));
    if (res.statusCode != 200) {
      throw Exception('Health check failed: ${res.statusCode}');
    }
    return jsonDecode(res.body) as Map<String, dynamic>;
  }
  Future<Map<String, dynamic>> predict() async {
    final url = Uri.parse('$baseUrl/predict');
    final res = await http.get(url).timeout(const Duration(seconds: 5));
    if (res.statusCode != 200) {
      throw Exception('Predict failed: ${res.statusCode}');
    }
    return jsonDecode(res.body) as Map<String, dynamic>;
  }
}

3-2. シンプルな UI で結果を表示
最小の画面では、次の 3 要素があれば十分です。
・「カメラで撮影して推論」ボタン
・推論結果(個数)
・最終更新時刻
lib/main.dart のイメージ:

import 'package:flutter/material.dart';
import 'api_client.dart';
void main() {
  runApp(const EdgeDemoApp());
}
class EdgeDemoApp extends StatelessWidget {
  const EdgeDemoApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Edge AI Inventory Demo',
      theme: ThemeData.dark(),
      home: const InventoryPage(),
    );
  }
}
class InventoryPage extends StatefulWidget {
  const InventoryPage({super.key});
  @override
  State<InventoryPage> createState() => _InventoryPageState();
}
class _InventoryPageState extends State<InventoryPage> {
  final _api = EdgeApiClient();
  int? _count;
  String? _lastUpdated;
  String? _error;
  bool _isLoading = false;
  Future<void> _runInference() async {
    setState(() {
      _isLoading = true;
      _error = null;
    });
    try {
      final result = await _api.predict();
      setState(() {
        _count = result['count'] as int?;
        _lastUpdated = result['timestamp'] as String?;
      });
    } catch (e) {
      setState(() {
        _error = e.toString();
      });
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Edge AI Inventory Demo'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            ElevatedButton(
              onPressed: _isLoading ? null : _runInference,
              child: _isLoading
                  ? const CircularProgressIndicator()
                  : const Text('カメラで撮影して推論'),
            ),
            const SizedBox(height: 24),
            Text('推論結果(個数): ${_count ?? '-'}'),
            const SizedBox(height: 8),
            Text('最終更新時刻: ${_lastUpdated ?? '-'}'),
            const SizedBox(height: 16),
            if (_error != null) ...[
              const Divider(),
              const Text('エラー', style: TextStyle(color: Colors.red)),
              Text(
                _error!,
                style: const TextStyle(fontSize: 12),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

ここまで実装すると、
1.Edge 側で python server.py を起動
2.Flutter アプリを起動
3.ボタンを押すと、カメラで 1 枚撮影 → 推論 → 個数表示
という流れが、クラウドに依存せずローカルだけで完結します。

3-3. 「完全ローカル」であることの確認ポイント
ローカル完結を確認するため、次のようなチェックを行うと安心です。
・PC のネットワークケーブルを抜いた状態でも動作するか
・baseUrl が http://127.0.0.1:8000 以外になっていないか
・OS のファイアウォール設定でローカルアクセスが許可されているか
このあたりを一度確認しておくと、
「外部に情報が出ていかない Edge 構成」であることを説明しやすくなります。

Step 4: 最低限のエラーハンドリングとデバッグ
実際に現場で使い始めると、まず直面するのは「動いていないときに何が悪いか分かりにくい」という問題です。
最低限、次のような観点でエラーハンドリング・ログ出力を入れておくと運用しやすくなります。
・Edge 側
・カメラが認識されていない場合に、ログと明確なエラーメッセージを出す
・モデルファイルが見つからない場合に即時に起動失敗する
・health API で簡易チェックができるようにする

・Flutter 側
・タイムアウト・HTTP 500 などを捕捉し、ユーザー向けメッセージを表示
・「Edge 側が起動していません」のようなメッセージ化
・デバッグ用にレスポンスをコンソールログにも出力

PoC 段階では、UI にすべてを詰め込む必要はありませんが、
「どこまで到達しているか」を把握できるログ行を数行入れておくだけでも、
トラブルシュートの速度が大きく変わります。

発展パターン:ストリーミングやパフォーマンス改善
今回の構成は「ボタンを押すたびに 1 フレーム推論する」最小構成です。
実案件では、次のような発展パターンを検討します。
・ストリーミング(WebSocket 等)
・カメラを常時回し続け、一定間隔で推論結果をストリームとして送る
・Flutter 側では StreamBuilder などでリアルタイム更新
・推論頻度の間引き
・毎フレーム推論せず、0.5 秒ごと・1 秒ごとに推論
・カウント用途であれば多くの場合十分
・モデル・ハードウェアのチューニング
・モデルの軽量化(量子化・入力サイズ削減)
・NPU 搭載ボードの活用

・ローカル DB との統合
・推論結果を SQLite に蓄積
・日次・月次の在庫推移レポートを生成

これらは別記事で掘り下げる予定ですが、
最初の PoC 段階では「単発推論で OK」と割り切って始めると進めやすくなります。
まとめと次回予告
本記事では、第 2 回として
・学習済みモデルを TFLite として Edge デバイスに配置
・Python + FastAPI で USB カメラ付きのローカル推論サーバを構築
・Flutter からローカル HTTP API を叩き、結果を UI に表示
という、「最小の実装ループ」を具体的なコードイメージ付きで整理しました。
この構成まで動けば、
「クラウドに依存せず、1 台の PC 上で完結する Edge AI × Flutter アプリ」
のベースが出来上がったことになります。
次回以降は、
・WebSocket ベースでのストリーミング UI
・SQLite などローカル DB への記録とグラフ表示
・実際の現場導入を見据えた運用設計(ログ・更新・監視)
といったトピックも含めて、
より「業務システムとしての完成度」を高めるための方法を整理していく予定です。

Discussion