1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutterでカメラ検品を試作するML Kit・Core ML設計

1
Posted at

はじめに

スマホのカメラを使った検品というと、最初から「傷をAIで自動判定する」「不良品を自動で弾く」と考えたくなります。

ただ、業務システムやスマホアプリを作るとき、私は先に「どこまで機械に任せるのか」を決めるようにしています。画像認識も同じです。モデルの精度を上げる話より前に、人が目で確認している作業のどこを補助すると安全なのかを整理したほうが設計しやすくなります。

今回の試作では、Flutterでカメラ画面を作り、AndroidではML Kit、iOSではVision + Core MLを呼び出します。判定結果は自動確定に使わず、作業者へ「OK候補」「確認が必要」という材料を返すところまでにします。

持ち帰れるものは、FlutterからAndroid/iOSの画像分類を呼び出す最小構成と、検品用途で自動判定を広げすぎないためのルールです。

結論

カメラ検品の最初の試作では、動画を全フレーム解析するより「撮影した1枚を端末内で分類する」構成から始めます。
AndroidはML Kit、iOSはVision + Core MLへ分け、Flutter側には共通の結果だけを返します。
分類結果は確定値ではなく、人の目視確認を助ける候補として扱います。

🔧 環境

この記事は2026年9月27日時点の公式ドキュメントと公開パッケージ情報を確認して構成しています。

試作環境は次の前提にします。

項目 バージョン・構成
Flutter 3.47系
Dart Flutter 3.47付属版
camera 0.12.1
Android API 24以上
Android画像分類 ML Kit Image Labeling
ML Kit com.google.mlkit:image-labeling-custom:17.0.2を例示
iOS iOS 13以上
iOS画像分類 Vision + Core ML
モデル Androidは.tflite、iOSは.mlmodelから生成したモデル

Flutter 3.47は2026年8月にリリースされています。camera 0.12.1はAndroid SDK 24以上、iOS 13以上をサポートしています。

なお、ML Kitには一般的な物体を分類する標準モデルもありますが、検品で「正常」「要確認」のような業務固有分類をしたい場合、一般画像のラベルだけでは足りません。そのため今回は、学習済みの独自分類モデルを組み込む前提にします。

ここで重要なのは、この記事のコードだけで汎用的な傷検出器が完成するわけではないことです。何を正常とするかは対象物によって変わるため、モデルと学習データは別途用意する必要があります。

Flutterのカメラ部分については公式のcamera package、Android側はML Kit custom image labeling、iOS側はAppleのClassifying Images with Vision and Core MLを基準にしています。

実装

今回の構成は、リアルタイム動画解析ではありません。

作業者が対象物をカメラ中央へ合わせ、ボタンを押して1枚撮影します。その画像だけをネイティブ側へ渡し、分類結果をFlutterへ戻します。

この形にした理由は、カメラ、画像分類、業務判断を分離したかったからです。

カメラは画像を取得するだけです。ML KitやCore MLは候補を返すだけです。そして最終的な判定は確認画面で人が行います。

1. Flutterでカメラを用意する

まずpubspec.yamlへcameraを追加します。

dependencies:
  flutter:
    sdk: flutter
  camera: ^0.12.1

iOSではInfo.plistにカメラ利用目的を追加します。

<key>NSCameraUsageDescription</key>
<string>点検対象を撮影するためにカメラを使用します</string>

Android側にもカメラ権限を追加します。

<uses-permission android:name="android.permission.CAMERA" />

次に、Flutter側へネイティブ画像分類を呼ぶクラスを作ります。

import 'package:flutter/services.dart';

class InspectionResult {
  final String label;
  final double confidence;

  const InspectionResult({
    required this.label,
    required this.confidence,
  });

  factory InspectionResult.fromMap(Map<Object?, Object?> map) {
    return InspectionResult(
      label: map['label'] as String,
      confidence: (map['confidence'] as num).toDouble(),
    );
  }
}

class InspectionClassifier {
  static const _channel =
      MethodChannel('example.inspection/classifier');

  Future<InspectionResult> classify(String imagePath) async {
    final result =
        await _channel.invokeMapMethod<Object?, Object?>(
      'classify',
      {'imagePath': imagePath},
    );

    if (result == null) {
      throw StateError('classification result is null');
    }

    return InspectionResult.fromMap(result);
  }
}

Flutter側はML KitもCore MLも知りません。

imagePathを渡すと、

label
confidence

が返ってくる、という契約だけにしています。

プラットフォーム固有の事情をFlutter側まで持ち込まないのがポイントです。

2. 撮影後に1回だけ分類する

画面側ではCameraControllerでプレビューを表示し、撮影ボタンが押されたときだけ分類します。

主要部分をまとめると次のようになります。

import 'package:camera/camera.dart';
import 'package:flutter/material.dart';

class InspectionPage extends StatefulWidget {
  final CameraDescription camera;

  const InspectionPage({
    super.key,
    required this.camera,
  });

  @override
  State<InspectionPage> createState() => _InspectionPageState();
}

class _InspectionPageState extends State<InspectionPage> {
  late final CameraController _cameraController;
  final _classifier = InspectionClassifier();

  InspectionResult? _result;
  bool _processing = false;

  @override
  void initState() {
    super.initState();

    _cameraController = CameraController(
      widget.camera,
      ResolutionPreset.medium,
      enableAudio: false,
    );

    _cameraController.initialize().then((_) {
      if (mounted) {
        setState(() {});
      }
    });
  }

  Future<void> _inspect() async {
    if (_processing ||
        !_cameraController.value.isInitialized) {
      return;
    }

    setState(() {
      _processing = true;
      _result = null;
    });

    try {
      final photo = await _cameraController.takePicture();
      final result = await _classifier.classify(photo.path);

      if (!mounted) return;

      setState(() {
        _result = result;
      });
    } finally {
      if (mounted) {
        setState(() {
          _processing = false;
        });
      }
    }
  }

  @override
  void dispose() {
    _cameraController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (!_cameraController.value.isInitialized) {
      return const Scaffold(
        body: Center(child: CircularProgressIndicator()),
      );
    }

    return Scaffold(
      body: Stack(
        children: [
          Positioned.fill(
            child: CameraPreview(_cameraController),
          ),
          Align(
            alignment: Alignment.bottomCenter,
            child: SafeArea(
              child: Padding(
                padding: const EdgeInsets.all(24),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    if (_result != null)
                      Card(
                        child: Padding(
                          padding: const EdgeInsets.all(16),
                          child: Text(
                            '${_result!.label} '
                            '${(_result!.confidence * 100).toStringAsFixed(1)}%',
                          ),
                        ),
                      ),
                    const SizedBox(height: 12),
                    FilledButton(
                      onPressed: _processing ? null : _inspect,
                      child: Text(
                        _processing ? '判定中' : '撮影して確認',
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

ここではあえてstartImageStream()を使っていません。

検品画面を見ると、カメラ映像へ常時推論をかけたくなります。しかし試作段階では、処理負荷、発熱、同時実行制御、画面回転、フレーム破棄など、画像分類とは別の問題が一気に増えます。

まず「撮影ボタンを押した画像を正しく分類できる」ことを確認します。その後、本当に連続判定が必要だと分かってから広げるほうが切り分けやすいと考えています。

3. AndroidはML Kitへ渡す

AndroidではFlutterから受け取ったファイルをML Kitへ渡します。

カスタムモデルを使う場合、たとえば

android/app/src/main/assets/inspection_model.tflite

へTensorFlow Lite形式のモデルを置きます。

依存関係はML Kitの公式ドキュメントで、その時点の最新版を確認して固定します。記事執筆時点では、カスタムモデル用のImage Labeling APIが提供されています。

概念的なKotlinコードは次の形です。

import android.net.Uri
import com.google.mlkit.common.model.LocalModel
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.label.ImageLabeling
import com.google.mlkit.vision.label.custom.CustomImageLabelerOptions

private val localModel =
    LocalModel.Builder()
        .setAssetFilePath("inspection_model.tflite")
        .build()

private val options =
    CustomImageLabelerOptions.Builder(localModel)
        .setConfidenceThreshold(0.0f)
        .setMaxResultCount(3)
        .build()

private val labeler =
    ImageLabeling.getClient(options)

画像ファイルを受け取ったらInputImageへ変換します。

private fun classifyImage(
    imagePath: String,
    onSuccess: (Map<String, Any>) -> Unit,
    onError: (Exception) -> Unit
) {
    val image = InputImage.fromFilePath(
        applicationContext,
        Uri.fromFile(java.io.File(imagePath))
    )

    labeler.process(image)
        .addOnSuccessListener { labels ->
            val best = labels.maxByOrNull {
                it.confidence
            }

            if (best == null) {
                onSuccess(
                    mapOf(
                        "label" to "unknown",
                        "confidence" to 0.0
                    )
                )
                return@addOnSuccessListener
            }

            onSuccess(
                mapOf(
                    "label" to best.text,
                    "confidence" to best.confidence.toDouble()
                )
            )
        }
        .addOnFailureListener(onError)
}

FlutterのMethodChannelと接続します。

MethodChannel(
    flutterEngine.dartExecutor.binaryMessenger,
    "example.inspection/classifier"
).setMethodCallHandler { call, result ->

    if (call.method != "classify") {
        result.notImplemented()
        return@setMethodCallHandler
    }

    val imagePath =
        call.argument<String>("imagePath")

    if (imagePath == null) {
        result.error(
            "INVALID_ARGUMENT",
            "imagePath is required",
            null
        )
        return@setMethodCallHandler
    }

    classifyImage(
        imagePath = imagePath,
        onSuccess = { value ->
            result.success(value)
        },
        onError = { error ->
            result.error(
                "CLASSIFY_ERROR",
                error.message,
                null
            )
        }
    )
}

ML KitのImage Labelingはconfidenceを返しますが、ここで注意したいのは、confidenceをそのまま「品質が90%保証されている」と読まないことです。

あくまでモデルの出力です。

その値を業務上どのように扱うかは、モデル評価とは別に決めます。

4. iOSはVision + Core MLへ渡す

iOSではCore MLモデルをXcodeプロジェクトへ追加します。

今回は、

InspectionClassifier.mlmodel

という画像分類モデルを用意した想定にします。

Xcodeは.mlmodelからSwift用のモデルクラスを生成できます。ただし、この記事では生成クラスへの依存を小さくするため、コンパイル済みモデルのURLからMLModelを読み込む形を示します。

import CoreML
import Vision

final class InspectionClassifierService {

    private lazy var visionModel: VNCoreMLModel = {
        guard let url = Bundle.main.url(
            forResource: "InspectionClassifier",
            withExtension: "mlmodelc"
        ) else {
            fatalError("model not found")
        }

        do {
            let model = try MLModel(
                contentsOf: url
            )
            return try VNCoreMLModel(
                for: model
            )
        } catch {
            fatalError(
                "failed to load model: \(error)"
            )
        }
    }()
}

画像ファイルを読み、Visionへ渡します。

func classify(
    imagePath: String,
    completion: @escaping (
        Result<[String: Any], Error>
    ) -> Void
) {
    let url = URL(fileURLWithPath: imagePath)

    guard
        let imageSource =
            CGImageSourceCreateWithURL(
                url as CFURL,
                nil
            ),
        let cgImage =
            CGImageSourceCreateImageAtIndex(
                imageSource,
                0,
                nil
            )
    else {
        completion(
            .failure(ClassifierError.invalidImage)
        )
        return
    }

    let request = VNCoreMLRequest(
        model: visionModel
    ) { request, error in

        if let error {
            completion(.failure(error))
            return
        }

        guard
            let results =
                request.results
                    as? [VNClassificationObservation],
            let best = results.first
        else {
            completion(
                .success([
                    "label": "unknown",
                    "confidence": 0.0
                ])
            )
            return
        }

        completion(
            .success([
                "label": best.identifier,
                "confidence":
                    Double(best.confidence)
            ])
        )
    }

    request.imageCropAndScaleOption = .centerCrop

    let handler = VNImageRequestHandler(
        cgImage: cgImage,
        options: [:]
    )

    DispatchQueue.global(
        qos: .userInitiated
    ).async {
        do {
            try handler.perform([request])
        } catch {
            completion(.failure(error))
        }
    }
}

CGImageSourceを使うため、次もimportします。

import ImageIO

Appleのサンプルでも、Visionを介して画像をCore MLモデルへ渡す構成が示されています。Vision側で画像のcropやscaleを指定できるため、カメラ画像を直接モデルの入力サイズへ手作業で変換する処理を減らせます。

さらにAppleは、Core MLによる予測処理をメインスレッドの外で実行してUIの応答性を保つことを案内しています。そのため、このコードでもDispatchQueue.global側でperformしています。

Flutter側との接続はAndroidと同じ名前のMethodChannelにします。

let channel = FlutterMethodChannel(
    name: "example.inspection/classifier",
    binaryMessenger:
        controller.binaryMessenger
)

let classifier =
    InspectionClassifierService()

channel.setMethodCallHandler {
    call,
    result in

    guard call.method == "classify" else {
        result(FlutterMethodNotImplemented)
        return
    }

    guard
        let args =
            call.arguments as? [String: Any],
        let imagePath =
            args["imagePath"] as? String
    else {
        result(
            FlutterError(
                code: "INVALID_ARGUMENT",
                message: "imagePath is required",
                details: nil
            )
        )
        return
    }

    classifier.classify(
        imagePath: imagePath
    ) { response in
        DispatchQueue.main.async {
            switch response {
            case .success(let value):
                result(value)

            case .failure(let error):
                result(
                    FlutterError(
                        code: "CLASSIFY_ERROR",
                        message:
                            error.localizedDescription,
                        details: nil
                    )
                )
            }
        }
    }
}

AndroidとiOSで内部実装はかなり違います。

しかしFlutterから見れば、

final result =
    await classifier.classify(imagePath);

だけです。

この境界を保っておくと、あとからAndroid側だけモデルを変更しても、画面や業務フローへの影響を抑えられます。

5. 「OK / NG」をそのまま自動確定しない

検品用途では、ここが実装以上に重要だと考えています。

たとえばモデルが次のような分類を返すとします。

{
  "label": "ok",
  "confidence": 0.91
}

この結果を見て、

if (result.label == 'ok') {
  saveAsPassed();
}

と書くのは簡単です。

ただ、試作の段階ではそうしません。

私なら、少なくとも最初は次のような状態に分けます。

enum InspectionSuggestion {
  likelyOk,
  needsReview,
  unknown,
}

そしてモデル出力を「提案」へ変換します。

InspectionSuggestion toSuggestion(
  InspectionResult result,
) {
  if (result.label == 'ok' &&
      result.confidence >= 0.90) {
    return InspectionSuggestion.likelyOk;
  }

  if (result.confidence < 0.60) {
    return InspectionSuggestion.unknown;
  }

  return InspectionSuggestion.needsReview;
}

0.90や0.60は説明用の仮値です。実運用で使う閾値ではありません。

実際の閾値は、対象物、モデル、撮影条件、見逃した場合の影響を踏まえて検証する必要があります。

大事なのは、モデルのラベルをそのまま業務上の確定結果に変換せず、

モデル出力
    ↓
業務ルール
    ↓
人の確認
    ↓
確定

という一段を置くことです。

AIは判定そのものより、確認すべき場所を絞る下ごしらえに使う。この考え方なら、小さな試作から始めやすくなります。

6. 撮影条件をUI側でもそろえる

画像分類ではモデルだけでなく、入力画像の条件も結果に影響します。

たとえば同じ部品でも、

  • 対象が画面の端に寄っている
  • 背景に別の部品が映る
  • 光が反射している
  • 暗い
  • 距離が大きく違う
  • 部品の向きが違う

といった差があります。

そこでカメラ画面には、対象を置く範囲をガイドとして表示します。

class InspectionGuide extends StatelessWidget {
  const InspectionGuide({super.key});

  @override
  Widget build(BuildContext context) {
    return IgnorePointer(
      child: Center(
        child: Container(
          width: 260,
          height: 260,
          decoration: BoxDecoration(
            border: Border.all(
              width: 2,
              color: Colors.white,
            ),
            borderRadius:
                BorderRadius.circular(16),
          ),
        ),
      ),
    );
  }
}

カメラの上へ重ねます。

Stack(
  children: [
    Positioned.fill(
      child: CameraPreview(
        _cameraController,
      ),
    ),
    const Positioned.fill(
      child: InspectionGuide(),
    ),
  ],
)

これは機械学習の高度な処理ではありませんが、検品アプリではかなり大切です。

モデル側ですべての撮影条件を吸収しようとするより、「この枠の中へ対象を置いてください」とUIで条件をそろえたほうが、問題を整理しやすいからです。

⚠️ ハマりどころ

confidenceを品質保証の数字として表示しない

ML KitもVision + Core MLも分類結果にconfidenceを持てます。

ただし、

confidence = 0.95

だから「95%の確率で製品に問題がない」とは限りません。

AppleのCore ML関連ドキュメントでも、VisionがCore MLモデルのconfidenceをそのまま転送する場合があり、値を独自に正規化するものではないことが説明されています。

モデル出力のconfidenceと、現場で知りたい「不良を見逃す可能性」は同じ指標ではありません。

そのためUIには、モデル内部の値を大きく見せるより、

OK候補
確認してください
判定できません

のように、作業者が次に何をすればよいかを示すほうが扱いやすいと考えています。

AndroidとiOSで同じ結果になるとは限らない

今回の構成ではAndroidとiOSで別形式のモデルを使っています。

AndroidはTensorFlow Lite、iOSはCore MLです。同じ元モデルから変換したとしても、前処理、画像のcrop方法、量子化、変換方法などが違えば出力差が出る可能性があります。

したがって、

Flutterで同じ画面
=
AndroidとiOSで同じ推論結果

とは考えないほうが安全です。

端末ごとの確認用画像を用意して、両方で結果を見る工程が必要になります。

画面回転と画像方向を軽視しない

カメラで撮った画像には向きの情報があります。

「画面では正しく見えるのに、モデルへ渡した画像だけ90度回転している」という問題は、画像処理では切り分けを難しくします。

ML Kitの公式ドキュメントでも入力画像のrotationが扱われています。Vision側でも画像のorientationを指定できるAPIがあります。

試作では、分類が怪しいと感じたらモデルを疑う前に、

撮影したファイル
↓
ネイティブ側で読み込んだ画像
↓
モデルへ入力された向き

を確認します。

いきなりリアルタイム解析へ進まない

Flutterのcameraは画像ストリームを取得できますし、React NativeならVisionCameraのFrame Processorのような選択肢もあります。

リアルタイム処理は魅力的ですが、検品で本当に毎フレーム推論する必要があるかは別の話です。

静止画1枚で目的を満たせるなら、そのほうが処理の開始点と終了点が明確になります。再現用の画像も保存しやすく、モデルの比較もしやすくなります。

連続判定が必要になった場合は、

全フレームを処理しない
処理中は次の入力を捨てる
必要な解像度まで落とす
推論とUI更新を分離する

といった制御を追加します。

ML Kitの公式ドキュメントでも、リアルタイム用途では呼び出し頻度を抑えることが案内されています。

モデル更新方法は早めに決める

モデルをアプリへ同梱すると、オフラインでもすぐ使えます。

一方、モデルだけ差し替えたい場合にはアプリ更新が必要です。ML Kitではカスタムモデルをアプリへバンドルする方法のほか、外部から更新する構成も用意されています。

ただ、検品用途では「最新版へ自動更新できる」ことだけを利点として考えないほうがよいです。

昨日と今日でモデルが変われば、同じ画像でも結果が変わる可能性があります。

そのため実運用へ進めるなら、

model_version
app_version
platform
captured_at
prediction
confidence
human_result

くらいは判定記録と関連付けられる形にしておきたいところです。

モデルを更新できる仕組みと、どのモデルが使われたか追える仕組みはセットで考えます。

📝 目視点検を補助するための実装チェックリスト

今回の構成を別の検品アプリへ持っていくなら、私は次の順番で確認します。

[ ] 自動化したい作業ではなく、人を補助したい作業を決めた
[ ] カメラで撮る対象を一つに絞った
[ ] 撮影位置をUIのガイドでそろえた
[ ] 最初は静止画1枚の分類にした
[ ] AndroidとiOSの推論処理をFlutterから分離した
[ ] Flutterへ返す結果形式を共通化した
[ ] confidenceを品質保証値として扱っていない
[ ] 低confidence時の「判定不能」を用意した
[ ] モデル出力だけで検品結果を確定しない
[ ] 作業者が画像と候補を確認できる
[ ] AndroidとiOSをそれぞれ確認した
[ ] 画像のorientationを確認した
[ ] モデルのバージョンを記録できる
[ ] モデル更新時に再評価する手順を決めた

特に重要なのは「判定不能」を正常な状態として設計することです。

分類器を作ると、すべての入力をOKかNGのどちらかへ入れたくなります。しかし現実のカメラには、手ぶれ、影、別の物体、対象の欠落なども入ります。

分からないものを無理に分類するより、人へ戻せるほうが検品補助として扱いやすくなります。

まとめ

スマホのカメラとオンデバイスの画像分類を組み合わせれば、検品を補助する小さなアプリはFlutterから試作できます。

今回の構成では、Flutterは撮影と確認画面に集中させました。Android固有のML Kit、iOS固有のVision + Core MLはMethodChannelの向こうへ置き、labelとconfidenceだけを共通形式で受け取っています。

そして、技術的に一番大切なのは分類器を呼び出すコードではないと思っています。

どの画像をモデルへ渡すのか。判定できないときにどうするのか。モデルの答えをどこまで業務上の判断へ使うのか。この境界を先に決めておくことです。

最初から「AIが検品する」仕組みにせず、まずは撮影条件をそろえ、候補を表示し、人が確認する。

そこまでで役に立つことが確認できてから、連続解析や自動化の範囲を広げるほうが、スマホアプリとしても業務システムとしても扱いやすい構成になります。

参考文献

以下はいずれも2026年9月27日に内容を確認しています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?