flutter_tflite_image_classification_engine 0.0.2 copy "flutter_tflite_image_classification_engine: ^0.0.2" to clipboard
flutter_tflite_image_classification_engine: ^0.0.2 copied to clipboard

A lightweight Flutter package for running **TensorFlow Lite image classification** with custom `.tflite` models and label files.

example/lib/main.dart

import 'dart:async';
import 'dart:io';
import 'dart:typed_data';

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

List<CameraDescription> appCameras = [];

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  try {
    appCameras = await availableCameras();
  } catch (_) {
    appCameras = [];
  }

  runApp(const TfliteClassifierExampleApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'TFLite Classification Engine Example',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.green,
        useMaterial3: true,
      ),
      home: const ClassifierHomePage(),
    );
  }
}

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

  @override
  State<ClassifierHomePage> createState() => _ClassifierHomePageState();
}

class _ClassifierHomePageState extends State<ClassifierHomePage> {
  FlutterTfliteImageClassificationEngine? _engine;

  int _selectedIndex = 0;
  bool _isLoading = true;
  String? _error;

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

  Future<void> _loadEngine() async {
    try {
      final engine = await FlutterTfliteImageClassificationEngine.create(
        model: TfliteModel.asset('assets/models/model.tflite'),
        labels: TfliteLabels.asset('assets/models/labels.txt'),
        options: const TfliteClassifierOptions(
          mode: ClassificationMode.normal,
          topK: 3,
          minConfidence: 0.40,
          threads: 4,
          normalizationType: NormalizationType.zeroToOne,
          channelOrder: ChannelOrder.rgb,
          interpolation: ResizeInterpolation.linear,
          inputLayout: InputTensorLayout.nhwc,
          useSoftmax: false,
          unknownLabel: 'Unknown',
          runPreprocessInIsolate: true,
          realtimeOptions: RealtimeClassifierOptions(
            frameIntervalMs: 300,
            skipBusyFrames: true,
            emitSkippedFrames: false,
            maxStreamErrors: 5,
          ),
        ),
      );

      if (!mounted) return;

      setState(() {
        _engine = engine;
        _isLoading = false;
      });
    } catch (e) {
      if (!mounted) return;

      setState(() {
        _error = e.toString();
        _isLoading = false;
      });
    }
  }

  @override
  void dispose() {
    _engine?.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final engine = _engine;

    return Scaffold(
      appBar: AppBar(
        title: const Text('TFLite Classification Engine'),
        centerTitle: true,
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _error != null
          ? Center(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Text(
            'Engine load failed:\n$_error',
            textAlign: TextAlign.center,
          ),
        ),
      )
          : engine == null
          ? const Center(child: Text('Engine not available'))
          : IndexedStack(
        index: _selectedIndex,
        children: [
          NormalClassificationPage(engine: engine),
          RealtimeClassificationPage(engine: engine),
        ],
      ),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _selectedIndex,
        onDestinationSelected: (index) {
          setState(() {
            _selectedIndex = index;
          });
        },
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.image),
            label: 'Normal',
          ),
          NavigationDestination(
            icon: Icon(Icons.videocam),
            label: 'Realtime',
          ),
        ],
      ),
    );
  }
}

class NormalClassificationPage extends StatefulWidget {
  const NormalClassificationPage({
    super.key,
    required this.engine,
  });

  final FlutterTfliteImageClassificationEngine engine;

  @override
  State<NormalClassificationPage> createState() =>
      _NormalClassificationPageState();
}

class _NormalClassificationPageState extends State<NormalClassificationPage> {
  final ImagePicker _picker = ImagePicker();

  File? _selectedImage;
  ClassificationResult? _result;

  bool _isProcessing = false;

  Future<void> _pickAndClassify(ImageSource source) async {
    if (_isProcessing) return;

    try {
      final pickedFile = await _picker.pickImage(
        source: source,
        imageQuality: 90,
      );

      if (pickedFile == null) return;

      final imageFile = File(pickedFile.path);

      setState(() {
        _selectedImage = imageFile;
        _result = null;
        _isProcessing = true;
      });

      final result = await widget.engine.classifyImage(
        ImageInput.file(imageFile),
      );

      if (!mounted) return;

      setState(() {
        _result = result;
        _isProcessing = false;
      });
    } catch (e) {
      if (!mounted) return;

      setState(() {
        _isProcessing = false;
      });

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Classification failed: $e')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            Expanded(
              child: Container(
                width: double.infinity,
                clipBehavior: Clip.antiAlias,
                decoration: BoxDecoration(
                  color: Colors.grey.shade100,
                  borderRadius: BorderRadius.circular(16),
                  border: Border.all(color: Colors.grey.shade300),
                ),
                child: _selectedImage == null
                    ? const Center(
                  child: Text(
                    'No image selected',
                    style: TextStyle(fontSize: 18),
                  ),
                )
                    : Image.file(
                  _selectedImage!,
                  fit: BoxFit.contain,
                ),
              ),
            ),
            const SizedBox(height: 16),
            NormalResultCard(result: _result),
            const SizedBox(height: 16),
            if (_isProcessing)
              const Padding(
                padding: EdgeInsets.only(bottom: 16),
                child: CircularProgressIndicator(),
              ),
            Row(
              children: [
                Expanded(
                  child: FilledButton.icon(
                    onPressed: _isProcessing
                        ? null
                        : () => _pickAndClassify(ImageSource.camera),
                    icon: const Icon(Icons.camera_alt),
                    label: const Text('Camera'),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: FilledButton.icon(
                    onPressed: _isProcessing
                        ? null
                        : () => _pickAndClassify(ImageSource.gallery),
                    icon: const Icon(Icons.photo),
                    label: const Text('Gallery'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class NormalResultCard extends StatelessWidget {
  const NormalResultCard({
    super.key,
    required this.result,
  });

  final ClassificationResult? result;

  @override
  Widget build(BuildContext context) {
    final value = result;

    if (value == null) {
      return const ResultBox(text: 'Select image to classify');
    }

    if (!value.isSuccess) {
      return ResultBox(text: value.error ?? 'Classification failed');
    }

    if (value.isUnknown) {
      return ResultBox(
        text:
        'Unknown\nBest: ${value.bestLabel ?? '-'}\nConfidence: ${value.bestConfidencePercent ?? '0%'}',
      );
    }

    final buffer = StringBuffer();

    buffer.writeln('Best: ${value.bestLabel ?? '-'}');
    buffer.writeln('Confidence: ${value.bestConfidencePercent ?? '0%'}');
    buffer.writeln('Total Time: ${value.totalTimeMs}ms');
    buffer.writeln('');
    buffer.writeln('Top Results:');

    for (final item in value.predictions) {
      buffer.writeln('${item.label}: ${item.confidencePercent}');
    }

    return ResultBox(text: buffer.toString().trim());
  }
}

class RealtimeClassificationPage extends StatefulWidget {
  const RealtimeClassificationPage({
    super.key,
    required this.engine,
  });

  final FlutterTfliteImageClassificationEngine engine;

  @override
  State<RealtimeClassificationPage> createState() =>
      _RealtimeClassificationPageState();
}

class _RealtimeClassificationPageState extends State<RealtimeClassificationPage>
    with AutomaticKeepAliveClientMixin {
  CameraController? _cameraController;

  final StreamController<FrameInput> _frameController =
  StreamController<FrameInput>.broadcast();

  StreamSubscription<ClassificationResult>? _resultSubscription;

  ClassificationResult? _latestResult;

  bool _isCameraReady = false;
  bool _isStreaming = false;
  String? _error;

  DateTime _lastFrameSentTime = DateTime.fromMillisecondsSinceEpoch(0);

  @override
  bool get wantKeepAlive => true;

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

  Future<void> _initCamera() async {
    try {
      if (appCameras.isEmpty) {
        setState(() {
          _error = 'No camera found';
        });
        return;
      }

      final camera = appCameras.firstWhere(
            (item) => item.lensDirection == CameraLensDirection.back,
        orElse: () => appCameras.first,
      );

      final imageFormatGroup = Platform.isAndroid
          ? ImageFormatGroup.yuv420
          : ImageFormatGroup.bgra8888;

      final controller = CameraController(
        camera,
        ResolutionPreset.medium,
        enableAudio: false,
        imageFormatGroup: imageFormatGroup,
      );

      await controller.initialize();

      if (!mounted) return;

      setState(() {
        _cameraController = controller;
        _isCameraReady = true;
      });
    } catch (e) {
      if (!mounted) return;

      setState(() {
        _error = 'Camera init failed: $e';
      });
    }
  }

  void _listenClassificationStream() {
    _resultSubscription = widget.engine
        .classifyFrameStream(_frameController.stream)
        .listen(
          (result) {
        if (!mounted) return;
        if (result.isSkipped) return;

        setState(() {
          _latestResult = result;
        });
      },
      onError: (error) {
        if (!mounted) return;

        setState(() {
          _error = error.toString();
        });
      },
    );
  }

  Future<void> _startStream() async {
    final controller = _cameraController;

    if (controller == null || !controller.value.isInitialized) return;
    if (_isStreaming) return;

    setState(() {
      _isStreaming = true;
      _error = null;
      _latestResult = null;
    });

    await controller.startImageStream((CameraImage image) {
      final now = DateTime.now();

      if (now.difference(_lastFrameSentTime).inMilliseconds < 100) {
        return;
      }

      _lastFrameSentTime = now;

      if (_frameController.isClosed) return;

      final frame = _cameraImageToFrameInput(
        image: image,
        sensorOrientation: controller.description.sensorOrientation,
      );

      _frameController.add(frame);
    });
  }

  Future<void> _stopStream() async {
    final controller = _cameraController;

    if (controller == null) return;
    if (!_isStreaming) return;

    await controller.stopImageStream();

    if (!mounted) return;

    setState(() {
      _isStreaming = false;
    });
  }

  FrameInput _cameraImageToFrameInput({
    required CameraImage image,
    required int sensorOrientation,
  }) {
    if (image.format.group == ImageFormatGroup.yuv420) {
      return FrameInput.yuv420(
        width: image.width,
        height: image.height,
        rotation: sensorOrientation,
        planes: image.planes.map((plane) {
          return FramePlane(
            bytes: plane.bytes,
            bytesPerRow: plane.bytesPerRow,
            bytesPerPixel: plane.bytesPerPixel ?? 1,
          );
        }).toList(),
      );
    }

    final firstPlane = image.planes.first;

    return FrameInput.bgra8888(
      bytes: firstPlane.bytes,
      width: image.width,
      height: image.height,
      rotation: sensorOrientation,
      bytesPerRow: firstPlane.bytesPerRow,
    );
  }

  @override
  void dispose() {
    _stopStream();
    _resultSubscription?.cancel();
    _frameController.close();
    _cameraController?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);

    final controller = _cameraController;

    return SafeArea(
      child: Column(
        children: [
          Expanded(
            child: Container(
              width: double.infinity,
              color: Colors.black,
              child: _error != null
                  ? Center(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Text(
                    _error!,
                    textAlign: TextAlign.center,
                    style: const TextStyle(color: Colors.white),
                  ),
                ),
              )
                  : !_isCameraReady ||
                  controller == null ||
                  !controller.value.isInitialized
                  ? const Center(child: CircularProgressIndicator())
                  : Center(
                // IMPORTANT:
                // CameraPreview and CustomPaint must have the same size.
                // Otherwise the rectangle will be drawn on the full screen
                // instead of the actual camera preview/image area.
                child: AspectRatio(
                  aspectRatio: controller.value.aspectRatio,
                  child: Stack(
                    fit: StackFit.expand,
                    children: [
                      CameraPreview(controller),
                      CustomPaint(
                        painter: ClassificationOverlayPainter(
                          result: _latestResult,
                          isStreaming: _isStreaming,
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                RealtimeResultCard(result: _latestResult),
                const SizedBox(height: 12),
                SizedBox(
                  width: double.infinity,
                  height: 48,
                  child: FilledButton.icon(
                    onPressed: !_isCameraReady
                        ? null
                        : _isStreaming
                        ? _stopStream
                        : _startStream,
                    icon: Icon(_isStreaming ? Icons.stop : Icons.play_arrow),
                    label: Text(
                      _isStreaming ? 'Stop Realtime' : 'Start Realtime',
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class RealtimeResultCard extends StatelessWidget {
  const RealtimeResultCard({
    super.key,
    required this.result,
  });

  final ClassificationResult? result;

  @override
  Widget build(BuildContext context) {
    final value = result;

    if (value == null) {
      return const ResultBox(text: 'Realtime result will show here');
    }

    if (!value.isSuccess) {
      return ResultBox(text: value.error ?? 'Classification failed');
    }

    if (value.isUnknown) {
      return ResultBox(
        text:
        'Unknown • ${value.bestLabel ?? '-'} • ${value.bestConfidencePercent ?? '0%'}',
      );
    }

    return ResultBox(
      text:
      '${value.bestLabel ?? 'Unknown'}  •  ${value.bestConfidencePercent ?? '0%'}  •  ${value.totalTimeMs}ms',
    );
  }
}

class ClassificationOverlayPainter extends CustomPainter {
  ClassificationOverlayPainter({
    required this.result,
    required this.isStreaming,
  });

  final ClassificationResult? result;
  final bool isStreaming;

  @override
  void paint(Canvas canvas, Size size) {
    if (!isStreaming) return;

    final value = result;

    if (value == null ||
        !value.isSuccess ||
        value.isUnknown ||
        value.bestLabel == null) {
      return;
    }

    // Image classification model does not return real bounding boxes.
    // This rectangle represents the classification region/preview overlay.
    final rect = Rect.fromLTWH(
      size.width * 0.10,
      size.height * 0.18,
      size.width * 0.80,
      size.height * 0.52,
    );

    final borderPaint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 4
      ..color = Colors.greenAccent;

    final labelBackgroundPaint = Paint()
      ..style = PaintingStyle.fill
      ..color = Colors.black.withOpacity(0.70);

    canvas.drawRect(rect, borderPaint);

    final labelText =
        '${value.bestLabel ?? 'Unknown'}  ${value.bestConfidencePercent ?? '0%'}';

    final textPainter = TextPainter(
      text: TextSpan(
        text: labelText,
        style: const TextStyle(
          color: Colors.white,
          fontSize: 16,
          fontWeight: FontWeight.bold,
        ),
      ),
      textDirection: TextDirection.ltr,
      maxLines: 1,
    );

    textPainter.layout(maxWidth: size.width * 0.80);

    final labelTop = rect.top - 38 < 0 ? rect.top + 8 : rect.top - 38;

    final labelRect = Rect.fromLTWH(
      rect.left,
      labelTop,
      textPainter.width + 20,
      32,
    );

    canvas.drawRRect(
      RRect.fromRectAndRadius(labelRect, const Radius.circular(6)),
      labelBackgroundPaint,
    );

    textPainter.paint(
      canvas,
      Offset(labelRect.left + 10, labelRect.top + 6),
    );
  }

  @override
  bool shouldRepaint(covariant ClassificationOverlayPainter oldDelegate) {
    return oldDelegate.result != result ||
        oldDelegate.isStreaming != isStreaming;
  }
}

class ResultBox extends StatelessWidget {
  const ResultBox({
    super.key,
    required this.text,
  });

  final String text;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        color: Colors.green.withOpacity(0.08),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(
          color: Colors.green.withOpacity(0.25),
        ),
      ),
      child: Text(
        text,
        textAlign: TextAlign.center,
        style: const TextStyle(
          fontSize: 16,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }
}
1
likes
130
points
32
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A lightweight Flutter package for running **TensorFlow Lite image classification** with custom `.tflite` models and label files.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, image, tflite_flutter

More

Packages that depend on flutter_tflite_image_classification_engine