Google search engine
HomeDevelopersGoogle ML Kit for Flutter: Complete Guide to Barcode Scanning, Face Detection...

Google ML Kit for Flutter: Complete Guide to Barcode Scanning, Face Detection & Text Recognition (2026)

Google ML Kit for Flutter: Complete Guide to Barcode Scanning, Face Detection & Text Recognition (2026)

Machine learning has moved from research labs into the pockets of billions of users. Yet, integrating ML into a mobile app used to mean training models, converting them to TFLite, managing input tensors, and handling edge cases across iOS and Android. Google ML Kit changes the equation dramatically – giving Flutter developers production-ready, on-device ML APIs that just work.

In this guide you will learn how to integrate three of ML Kit’s most practical capabilities – barcode scanning, face detection, and text recognition (OCR) – into a Flutter app. Every code snippet is real, runnable Dart. By the end you will have a solid understanding of the APIs, performance patterns, and architectural decisions that make ML Kit a first-class tool in your Flutter toolkit.


What is Google ML Kit & Why Use It?

Google ML Kit is a mobile SDK that bundles a suite of on-device machine learning features. Released as part of Google’s Firebase ecosystem (and now also available standalone through the google_mlkit_* pub.dev packages), it exposes pre-trained, highly optimised models that run entirely on the device – no internet connection required, no server round-trips, no user data leaving the phone.

On-Device Inference: Why It Matters

Processing data locally means:

  • Privacy by default – camera frames never leave the device.
  • Low latency – inference happens in milliseconds, not seconds.
  • Offline-first – ML features work in aeroplanes, tunnels, and poor-coverage areas.
  • Cost savings – no cloud Vision API bills that scale with usage.

ML Kit vs. Custom TFLite Models

When should you reach for ML Kit instead of a custom TFLite model?

Factor ML Kit Custom TFLite
Training data required None Thousands of labelled samples
Domain coverage Common tasks (text, faces, barcodes.) Anything – including niche domains
Maintenance Google-maintained You maintain it
Model size Bundled or streamed by Google Play Bundled in APK/IPA
Cross-platform iOS + Android out of the box Requires platform channels for full parity

The rule of thumb: if your task is a commodity ML problem – reading text, detecting faces, decoding barcodes – ML Kit saves you weeks of work. Reserve custom models for domain-specific needs where no pre-trained solution exists.

Key ML Kit Features Available in Flutter

  • Barcode Scanning
  • Face Detection & Mesh
  • Text Recognition v2
  • Image Labelling
  • Object Detection & Tracking
  • Pose Detection
  • Language ID & Translation
  • Smart Reply

Barcode Scanning Setup & Implementation

Real-time barcode scanning is one of the most requested features in retail, logistics, and ticketing apps. ML Kit’s barcode scanner supports QR codes, EAN-13, UPC-A, Code 128, PDF417, Data Matrix, and many more formats – all decoded on-device at camera speed.

Dependencies

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  google_mlkit_barcode_scanning: ^0.12.0
  camera: ^0.11.0
  permission_handler: ^11.3.0

Run flutter pub get and add camera permission entries to your platform manifests:

  • Android (AndroidManifest.xml): <uses-permission android:name="android.permission.CAMERA"/>
  • iOS (Info.plist): NSCameraUsageDescription key with a human-readable string.

Full Barcode Scanner Implementation

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

class BarcodeScannerScreen extends StatefulWidget {
  const BarcodeScannerScreen({super.key});

  @override
  State<BarcodeScannerScreen> createState() => _BarcodeScannerScreenState();
}

class _BarcodeScannerScreenState extends State<BarcodeScannerScreen> {
  late CameraController _cameraController;
  late BarcodeScanner _barcodeScanner;
  bool _isProcessing = false;
  String _scannedValue = 'Point camera at a barcode';
  bool _isCameraReady = false;

  @override
  void initState() {
    super.initState();
    _barcodeScanner = BarcodeScanner(
      formats: [BarcodeFormat.all],
    );
    _initCamera();
  }

  Future<void> _initCamera() async {
    final cameras = await availableCameras();
    final backCamera = cameras.firstWhere(
      (c) => c.lensDirection == CameraLensDirection.back,
      orElse: () => cameras.first,
    );

    _cameraController = CameraController(
      backCamera,
      ResolutionPreset.high,
      enableAudio: false,
      imageFormatGroup: ImageFormatGroup.nv21,
    );

    await _cameraController.initialize();
    if (!mounted) return;

    setState(() => _isCameraReady = true);

    _cameraController.startImageStream(_processFrame);
  }

  Future<void> _processFrame(CameraImage image) async {
    if (_isProcessing) return;
    _isProcessing = true;

    try {
      final inputImage = _buildInputImage(image);
      if (inputImage == null) {
        _isProcessing = false;
        return;
      }

      final barcodes = await _barcodeScanner.processImage(inputImage);

      if (barcodes.isNotEmpty && mounted) {
        final barcode = barcodes.first;
        setState(() => _scannedValue = barcode.displayValue ?? barcode.rawValue ?? 'Unknown');
      }
    } catch (e) {
      debugPrint('Barcode scan error: $e');
    } finally {
      _isProcessing = false;
    }
  }

  InputImage? _buildInputImage(CameraImage image) {
    final camera = _cameraController.description;
    final rotation = InputImageRotationValue.fromRawValue(
      camera.sensorOrientation,
    );
    if (rotation == null) return null;

    final format = InputImageFormatValue.fromRawValue(image.format.raw);
    if (format == null) return null;

    final plane = image.planes.first;
    return InputImage.fromBytes(
      bytes: plane.bytes,
      metadata: InputImageMetadata(
        size: Size(image.width.toDouble(), image.height.toDouble()),
        rotation: rotation,
        format: format,
        bytesPerRow: plane.bytesPerRow,
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Barcode Scanner')),
      body: Column(
        children: [
          Expanded(
            child: _isCameraReady
                ? CameraPreview(_cameraController)
                : const Center(child: CircularProgressIndicator()),
          ),
          Container(
            width: double.infinity,
            color: Colors.black87,
            padding: const EdgeInsets.all(16),
            child: Text(
              _scannedValue,
              style: const TextStyle(color: Colors.white, fontSize: 16),
              textAlign: TextAlign.center,
            ),
          ),
        ],
      ),
    );
  }
}

Key Implementation Notes

  • _isProcessing flag: Camera streams deliver frames at 30+ fps. Without this guard, you will queue hundreds of concurrent ML operations and crash. Always skip a frame if the previous one has not finished processing.
  • ImageFormatGroup.nv21: Use nv21 on Android and bgra8888 on iOS. The ML Kit plugin handles the difference, but you must set the correct group on the CameraController.
  • ResolutionPreset.high: Gives a good balance between scanning distance and CPU load. Use medium for faster processing, veryHigh only when scanning small 1D barcodes at distance.
  • Always call _barcodeScanner.close() in dispose() to release native resources.

Face Detection with Bounding Box Overlay

Face detection is central to photo apps, AR filters, attendance systems, and accessibility features. ML Kit’s face detector locates faces in an image and – optionally – returns landmarks (eyes, nose, mouth), contours, and classification scores (smiling probability, eyes-open probability).

Dependencies

# pubspec.yaml
dependencies:
  google_mlkit_face_detection: ^0.11.0
  camera: ^0.11.0

FaceDetectorOptions Explained

final FaceDetector _faceDetector = FaceDetector(
  options: FaceDetectorOptions(
    enableClassification: true,
    enableLandmarks: true,
    enableContours: false,
    enableTracking: true,
    minFaceSize: 0.1,
    performanceMode: FaceDetectorMode.fast,
  ),
);

Use FaceDetectorMode.fast for live camera feeds and FaceDetectorMode.accurate for processing still images where latency is acceptable.

Full Face Detection Implementation with CustomPainter

import 'dart:ui' as ui;
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';

class FacePainter extends CustomPainter {
  FacePainter({
    required this.faces,
    required this.imageSize,
    required this.isFrontCamera,
  });

  final List<Face> faces;
  final Size imageSize;
  final bool isFrontCamera;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.greenAccent
      ..strokeWidth = 2.5
      ..style = PaintingStyle.stroke;

    final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);

    for (final face in faces) {
      final rect = _scaleRect(face.boundingBox, imageSize, size);
      canvas.drawRect(rect, paint);

      if (face.smilingProbability != null) {
        final label =
            'Smile: ${(face.smilingProbability! * 100).toStringAsFixed(0)}%';
        textPainter.text = TextSpan(
          text: label,
          style: const TextStyle(color: Colors.greenAccent, fontSize: 14),
        );
        textPainter.layout();
        textPainter.paint(canvas, rect.topLeft - const Offset(0, 18));
      }
    }
  }

  Rect _scaleRect(Rect src, Size imageSize, Size canvasSize) {
    final scaleX = canvasSize.width / imageSize.width;
    final scaleY = canvasSize.height / imageSize.height;

    double left = src.left * scaleX;
    double right = src.right * scaleX;

    if (isFrontCamera) {
      left = canvasSize.width - src.right * scaleX;
      right = canvasSize.width - src.left * scaleX;
    }

    return Rect.fromLTRB(left, src.top * scaleY, right, src.bottom * scaleY);
  }

  @override
  bool shouldRepaint(FacePainter oldDelegate) =>
      oldDelegate.faces != faces || oldDelegate.imageSize != imageSize;
}

class FaceDetectionScreen extends StatefulWidget {
  const FaceDetectionScreen({super.key});

  @override
  State<FaceDetectionScreen> createState() => _FaceDetectionScreenState();
}

class _FaceDetectionScreenState extends State<FaceDetectionScreen> {
  late CameraController _cameraController;
  late FaceDetector _faceDetector;
  bool _isProcessing = false;
  List<Face> _faces = [];
  Size _imageSize = Size.zero;
  bool _isCameraReady = false;
  bool _isFrontCamera = true;

  @override
  void initState() {
    super.initState();
    _faceDetector = FaceDetector(
      options: FaceDetectorOptions(
        enableClassification: true,
        enableLandmarks: true,
        performanceMode: FaceDetectorMode.fast,
      ),
    );
    _initCamera();
  }

  Future<void> _initCamera() async {
    final cameras = await availableCameras();
    final frontCamera = cameras.firstWhere(
      (c) => c.lensDirection == CameraLensDirection.front,
      orElse: () => cameras.first,
    );
    _isFrontCamera = frontCamera.lensDirection == CameraLensDirection.front;

    _cameraController = CameraController(
      frontCamera,
      ResolutionPreset.medium,
      enableAudio: false,
      imageFormatGroup: ImageFormatGroup.nv21,
    );

    await _cameraController.initialize();
    if (!mounted) return;

    final size = _cameraController.value.previewSize!;
    _imageSize = Size(size.height, size.width);

    setState(() => _isCameraReady = true);
    _cameraController.startImageStream(_processFrame);
  }

  Future<void> _processFrame(CameraImage image) async {
    if (_isProcessing) return;
    _isProcessing = true;

    try {
      final inputImage = _buildInputImage(image);
      if (inputImage == null) return;

      final faces = await _faceDetector.processImage(inputImage);
      if (mounted) {
        setState(() => _faces = faces);
      }
    } catch (e) {
      debugPrint('Face detection error: $e');
    } finally {
      _isProcessing = false;
    }
  }

  InputImage? _buildInputImage(CameraImage image) {
    final camera = _cameraController.description;
    final rotation =
        InputImageRotationValue.fromRawValue(camera.sensorOrientation);
    if (rotation == null) return null;
    final format = InputImageFormatValue.fromRawValue(image.format.raw);
    if (format == null) return null;
    final plane = image.planes.first;
    return InputImage.fromBytes(
      bytes: plane.bytes,
      metadata: InputImageMetadata(
        size: Size(image.width.toDouble(), image.height.toDouble()),
        rotation: rotation,
        format: format,
        bytesPerRow: plane.bytesPerRow,
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    if (!_isCameraReady) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    return Scaffold(
      appBar: AppBar(title: Text('Face Detection (${_faces.length} faces)')),
      body: Stack(
        fit: StackFit.expand,
        children: [
          CameraPreview(_cameraController),
          CustomPaint(
            painter: FacePainter(
              faces: _faces,
              imageSize: _imageSize,
              isFrontCamera: _isFrontCamera,
            ),
          ),
        ],
      ),
    );
  }
}

Front Camera Mirroring

The front camera captures an unmirrored image (the raw sensor output), but Flutter’s CameraPreview displays it mirrored for a natural “selfie” feel. Your bounding boxes must apply the same horizontal mirror transform – see the _scaleRect method above – otherwise boxes will appear on the wrong side of the face.


Text Recognition (OCR)

Text recognition – often called OCR (Optical Character Recognition) – lets your app read printed text from camera frames or static images. Use cases include business card scanners, document digitisation, receipt parsers, and real-time translation overlays.

Dependencies

# pubspec.yaml
dependencies:
  google_mlkit_text_recognition: ^0.13.0
  camera: ^0.11.0

Frame Throttling – Why It Is Essential

OCR is considerably heavier than barcode scanning. On mid-range devices, a single recognition call can take 80-200 ms. Running it on every frame (30 fps) would queue frames faster than they can be processed, leading to memory pressure and dropped UI frames. The solution is a timestamp-based throttle: only submit a new frame if at least 500 ms have passed since the last submission.

Full OCR Implementation

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

class TextRecognitionScreen extends StatefulWidget {
  const TextRecognitionScreen({super.key});

  @override
  State<TextRecognitionScreen> createState() => _TextRecognitionScreenState();
}

class _TextRecognitionScreenState extends State<TextRecognitionScreen> {
  late CameraController _cameraController;
  final TextRecognizer _textRecognizer =
      TextRecognizer(script: TextRecognitionScript.latin);

  bool _isProcessing = false;
  String _recognisedText = '';
  DateTime? _lastProcessedAt;
  bool _isCameraReady = false;

  static const Duration _throttleDuration = Duration(milliseconds: 500);

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

  Future<void> _initCamera() async {
    final cameras = await availableCameras();
    final backCamera = cameras.firstWhere(
      (c) => c.lensDirection == CameraLensDirection.back,
      orElse: () => cameras.first,
    );

    _cameraController = CameraController(
      backCamera,
      ResolutionPreset.medium,
      enableAudio: false,
      imageFormatGroup: ImageFormatGroup.nv21,
    );

    await _cameraController.initialize();
    if (!mounted) return;

    setState(() => _isCameraReady = true);
    _cameraController.startImageStream(_processFrame);
  }

  Future<void> _processFrame(CameraImage image) async {
    final now = DateTime.now();
    if (_lastProcessedAt != null &&
        now.difference(_lastProcessedAt!) < _throttleDuration) {
      return;
    }

    if (_isProcessing) return;
    _isProcessing = true;
    _lastProcessedAt = now;

    try {
      final inputImage = _buildInputImage(image);
      if (inputImage == null) return;

      final RecognizedText result =
          await _textRecognizer.processImage(inputImage);

      final buffer = StringBuffer();
      for (final block in result.blocks) {
        for (final line in block.lines) {
          buffer.writeln(line.text);
        }
      }

      if (mounted) {
        setState(() => _recognisedText =
            buffer.toString().trim().isEmpty ? 'No text found' : buffer.toString().trim());
      }
    } catch (e) {
      debugPrint('OCR error: $e');
    } finally {
      _isProcessing = false;
    }
  }

  InputImage? _buildInputImage(CameraImage image) {
    final camera = _cameraController.description;
    final rotation =
        InputImageRotationValue.fromRawValue(camera.sensorOrientation);
    if (rotation == null) return null;
    final format = InputImageFormatValue.fromRawValue(image.format.raw);
    if (format == null) return null;
    final plane = image.planes.first;
    return InputImage.fromBytes(
      bytes: plane.bytes,
      metadata: InputImageMetadata(
        size: Size(image.width.toDouble(), image.height.toDouble()),
        rotation: rotation,
        format: format,
        bytesPerRow: plane.bytesPerRow,
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Live OCR')),
      body: Column(
        children: [
          Expanded(
            flex: 2,
            child: _isCameraReady
                ? CameraPreview(_cameraController)
                : const Center(child: CircularProgressIndicator()),
          ),
          Expanded(
            flex: 1,
            child: SingleChildScrollView(
              padding: const EdgeInsets.all(12),
              child: Text(
                _recognisedText.isEmpty ? 'Waiting for text.' : _recognisedText,
                style: const TextStyle(fontSize: 15),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Handling Multiple Scripts

The TextRecognitionScript enum supports latin, chinese, devanagari, japanese, korean, and georgian. Each script uses a different bundled model. You can instantiate multiple TextRecognizer objects simultaneously if your app needs to handle mixed-script input, though this increases memory usage.


Performance Optimisation

Combining camera streams with on-device ML is demanding. Without careful engineering, your app will drain the battery and drop frames. Here are the essential patterns.

The _isProcessing Flag Pattern

All three implementations above use a boolean guard to prevent frame queue build-up. This is the single most important pattern for camera-based ML in Flutter:

bool _isProcessing = false;

void _processFrame(CameraImage image) async {
  if (_isProcessing) return;
  _isProcessing = true;
  try {
    // ... ML Kit call ...
  } finally {
    _isProcessing = false;
  }
}

The finally block ensures the flag is always cleared, even when an exception is thrown – preventing permanent lock-up of the pipeline.

Choosing the Right ResolutionPreset

Preset Typical Resolution Best for
low 240p Fast prototyping only
medium 480p OCR, face detection
high 720p Barcode scanning, small text
veryHigh 1080p High-quality still captures
ultraHigh 2160p+ Rarely needed; very slow

Always pick the lowest preset that satisfies your accuracy requirement. Halving the resolution (e.g., from 1080p to 480p) reduces pixel count by ~80% and typically more than doubles processing speed.

Closing Detectors in dispose()

Every ML Kit object holds native resources – model weights loaded into memory, interpreter sessions, GPU delegate handles. If you forget to call .close(), you will see memory leaks that grow with each navigation to and from the screen.

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

Always stop the image stream before disposing the camera controller; otherwise callbacks may fire after disposal and cause a use-after-free crash.

Using compute() for Heavy Post-Processing

ML Kit’s inference runs on a native thread and does not block the Dart VM. However, if you perform heavy post-processing of results (e.g., building a complex structured document from OCR blocks), offload it to an isolate using Flutter’s compute():

Map<String, dynamic> parseOcrResult(RecognizedText recognizedText) {
  final lines = <String>[];
  for (final block in recognizedText.blocks) {
    for (final line in block.lines) {
      lines.add(line.text);
    }
  }
  return {'lines': lines, 'count': lines.length};
}

// In your widget:
final parsed = await compute(parseOcrResult, result);

Battery Optimisation Tips

  • Pause the stream when the app goes to background – listen to AppLifecycleState and call stopImageStream() / startImageStream() accordingly.
  • Throttle aggressively when a result has been found. For example, once a barcode is decoded, pause scanning for 2-3 seconds before resuming.
  • Disable unneeded detector options – enabling contours or landmarks in the face detector roughly doubles processing time and power draw.
  • Use FaceDetectorMode.fast over accurate unless you specifically need landmark precision for an AR use case.

Conclusion

Google ML Kit removes the biggest barrier to mobile ML adoption – the need to train, optimise, and deploy your own models. With the google_mlkit_* Flutter packages, you get:

  • Real-time barcode scanning across dozens of formats with a handful of lines of Dart.
  • Face detection with bounding boxes, landmarks, and emotion classification – rendered live with CustomPainter.
  • On-device text recognition supporting Latin and several CJK scripts, throttled to stay smooth on any device.

The architectural patterns – the _isProcessing guard, timestamp throttling, correct dispose() ordering, and appropriate ResolutionPreset – are the difference between a demo that crashes and a production feature your users trust.

As ML Kit continues to expand (on-device translation, document scanning, subject segmentation), mastering these foundations puts you in an excellent position to add new capabilities with minimal effort.

Want more Flutter tips? Explore more tutorials on FlutterExperts.com and level up your app development skills today!

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

Recent Comments