flutter_tflite_image_classification_engine 0.0.2
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.
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,
),
),
);
}
}