object_detection 0.3.0 copy "object_detection: ^0.3.0" to clipboard
object_detection: ^0.3.0 copied to clipboard

On-device object detection using MediaPipe EfficientDet-Lite models in LiteRT (formerly TensorFlow Lite) format. 80 COCO classes, fully offline.

example/lib/main.dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:camera/camera.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_litert/flutter_litert.dart' show FrameThrottle;
import 'package:image_picker/image_picker.dart';
import 'package:object_detection/object_detection.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:video_player/video_player.dart';

import 'video_pipeline.dart';

void main() {
  runApp(const ObjectDetectionApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'object_detection demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

/// The default model used across all screens (higher accuracy; nicer boxes for
/// the demo). Live camera can switch to Lite0 for higher FPS.
const ObjectDetectionModel kDefaultModel =
    ObjectDetectionModel.efficientDetLite2;

String modelLabel(ObjectDetectionModel m) => switch (m) {
      ObjectDetectionModel.efficientDetLite0 => 'Lite0',
      ObjectDetectionModel.efficientDetLite2 => 'Lite2',
    };

/// Curated COCO classes exposed as one-tap filter chips (allowlist). Empty
/// selection means "all classes".
const List<String> kFilterClasses = [
  'person',
  'bicycle',
  'car',
  'motorcycle',
  'bus',
  'truck',
  'cat',
  'dog',
  'bird',
  'bottle',
  'chair',
  'laptop',
  'cell phone',
  'tv',
  'book',
];

// ─────────────────────────────── Home Menu ────────────────────────────────

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Object Detection Demo')),
      body: _ScrollableCentered(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 720),
          child: Padding(
            padding: const EdgeInsets.symmetric(vertical: 24),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 16),
                  child: Text(
                    'Choose a Demo',
                    style: Theme.of(context).textTheme.headlineMedium,
                  ),
                ),
                const SizedBox(height: 28),
                _buildSection(
                  context,
                  'Object Detection',
                  [
                    _buildModeCard(
                      context,
                      icon: Icons.videocam,
                      title: 'Live Camera',
                      description:
                          'Real-time object detection from camera feed',
                      onTap: () => Navigator.push(
                        context,
                        MaterialPageRoute(
                            builder: (_) => const LiveCameraScreen()),
                      ),
                    ),
                    _buildModeCard(
                      context,
                      icon: Icons.image,
                      title: 'Still Image',
                      description:
                          'Detect objects in photos from gallery or samples',
                      onTap: () => Navigator.push(
                        context,
                        MaterialPageRoute(
                            builder: (_) => const StillImageScreen()),
                      ),
                    ),
                    _buildModeCard(
                      context,
                      icon: Icons.movie_creation_outlined,
                      title: 'Video File',
                      description:
                          'Process an MP4 frame-by-frame with smoothed, '
                          'tracked detections',
                      onTap: () => Navigator.push(
                        context,
                        MaterialPageRoute(
                            builder: (_) => const VideoFileScreen()),
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildSection(BuildContext context, String title, List<Widget> cards) {
    final List<Widget> row = [];
    for (int i = 0; i < cards.length; i++) {
      if (i > 0) row.add(const SizedBox(width: 12));
      row.add(cards[i]);
    }
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Text(
            title,
            style: Theme.of(context).textTheme.titleMedium?.copyWith(
                  fontWeight: FontWeight.w600,
                  color: Colors.grey[700],
                ),
          ),
        ),
        const SizedBox(height: 12),
        SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: IntrinsicHeight(
            child: Row(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: row,
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildModeCard(
    BuildContext context, {
    required IconData icon,
    required String title,
    required String description,
    required VoidCallback onTap,
  }) {
    return SizedBox(
      width: 190,
      child: Card(
        elevation: 4,
        clipBehavior: Clip.antiAlias,
        child: InkWell(
          onTap: onTap,
          borderRadius: BorderRadius.circular(12),
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(icon, size: 40, color: Colors.indigo),
                const SizedBox(height: 12),
                Text(title,
                    style: Theme.of(context).textTheme.titleMedium,
                    textAlign: TextAlign.center),
                const SizedBox(height: 6),
                Text(
                  description,
                  style: Theme.of(context)
                      .textTheme
                      .bodySmall
                      ?.copyWith(color: Colors.grey[600]),
                  textAlign: TextAlign.center,
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

// ───────────────────────────── Still Image ────────────────────────────────

class StillImageScreen extends StatefulWidget {
  const StillImageScreen({super.key});
  @override
  State<StillImageScreen> createState() => _StillImageScreenState();
}

class _StillImageScreenState extends State<StillImageScreen> {
  ObjectDetector? _detector;
  ObjectDetectionModel _model = kDefaultModel;

  Uint8List? _imageBytes;
  Size? _originalSize;
  List<DetectedObject> _detections = const [];
  bool _isLoading = false;
  int? _detectionTimeMs;

  // Settings.
  bool _showBoundingBoxes = true;
  bool _showLabels = true;
  bool _perClassColors = true;
  Color _boundingBoxColor = const Color(0xFF00FFCC);
  double _boundingBoxThickness = 2.0;
  double _labelFontSize = 12.0;
  double _scoreThreshold = 0.5;
  int _maxResults = 15;
  final Set<String> _allowClasses = {};

  static const _samples = <(String, String)>[
    ('Cat & TV', 'assets/samples/cat.jpg'),
    ('Dog', 'assets/samples/dog.jpg'),
    ('People', 'assets/samples/people.jpg'),
    ('Street', 'assets/samples/street.jpg'),
    ('Kitchen', 'assets/samples/kitchen.jpg'),
  ];

  @override
  void initState() {
    super.initState();
    _initDetector(loadFirstSample: true);
  }

  Future<void> _initDetector({bool loadFirstSample = false}) async {
    try {
      await _detector?.dispose();
      _detector = await ObjectDetector.create(model: _model);
    } catch (_) {}
    if (!mounted) return;
    setState(() {});
    if (loadFirstSample) await _loadSample(_samples.first.$2);
  }

  ObjectDetectorOptions get _options => ObjectDetectorOptions(
        scoreThreshold: _scoreThreshold,
        maxResults: _maxResults,
        categoryAllowlist: _allowClasses.toList(),
      );

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

  Future<void> _pickAndRun() async {
    final picker = ImagePicker();
    final picked =
        await picker.pickImage(source: ImageSource.gallery, imageQuality: 100);
    if (picked == null) return;
    final bytes = await picked.readAsBytes();
    await _setBytes(bytes);
  }

  Future<void> _loadSample(String assetPath) async {
    final data = await rootBundle.load(assetPath);
    await _setBytes(data.buffer.asUint8List());
  }

  Future<void> _setBytes(Uint8List bytes) async {
    setState(() {
      _imageBytes = bytes;
      _detections = const [];
      _isLoading = true;
    });
    await _runDetection(bytes);
  }

  Future<void> _runDetection(Uint8List bytes) async {
    final det = _detector;
    if (det == null || !det.isReady) {
      setState(() => _isLoading = false);
      return;
    }
    setState(() => _isLoading = true);
    final sw = Stopwatch()..start();
    try {
      final results = await det.detect(bytes, options: _options);
      sw.stop();
      Size size;
      if (results.isNotEmpty) {
        size = results.first.originalSize;
      } else {
        final ui = await decodeImageFromList(bytes);
        size = Size(ui.width.toDouble(), ui.height.toDouble());
      }
      if (!mounted) return;
      setState(() {
        _detections = results;
        _originalSize = size;
        _detectionTimeMs = sw.elapsedMilliseconds;
        _isLoading = false;
      });
    } catch (_) {
      if (mounted) setState(() => _isLoading = false);
    }
  }

  Future<void> _switchModel(ObjectDetectionModel m) async {
    setState(() {
      _model = m;
      _isLoading = true;
    });
    await _initDetector();
    if (_imageBytes != null) await _runDetection(_imageBytes!);
  }

  void _showSettingsSheet() {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (context) => DraggableScrollableSheet(
        initialChildSize: 0.6,
        minChildSize: 0.3,
        maxChildSize: 0.9,
        builder: (context, scrollController) => StatefulBuilder(
          builder: (context, setSheetState) {
            void update(VoidCallback fn, {bool rerun = false}) {
              fn();
              setSheetState(() {});
              setState(() {});
              if (rerun && _imageBytes != null) _runDetection(_imageBytes!);
            }

            Widget cb(String label, bool v, void Function(bool) set,
                    {bool rerun = false}) =>
                CompactCheckbox(
                    label: label,
                    value: v,
                    onChanged: (x) =>
                        update(() => set(x ?? false), rerun: rerun));
            Widget sl(String label, double v, double mn, double mx,
                    void Function(double) set, {bool rerun = false}) =>
                CompactSlider(
                    label: label,
                    value: v,
                    min: mn,
                    max: mx,
                    onChanged: (x) => update(() => set(x), rerun: rerun));

            return Material(
              color: Colors.white,
              borderRadius:
                  const BorderRadius.vertical(top: Radius.circular(16)),
              child: Column(
                children: [
                  Container(
                    margin: const EdgeInsets.symmetric(vertical: 8),
                    width: 40,
                    height: 4,
                    decoration: BoxDecoration(
                      color: Colors.grey[300],
                      borderRadius: BorderRadius.circular(2),
                    ),
                  ),
                  Expanded(
                    child: ListView(
                      controller: scrollController,
                      padding: const EdgeInsets.symmetric(horizontal: 16),
                      children: [
                        ExpansionTile(
                          title: const Text('Display Options',
                              style: TextStyle(fontWeight: FontWeight.bold)),
                          initiallyExpanded: true,
                          children: [
                            Wrap(
                              spacing: 8,
                              runSpacing: 4,
                              children: [
                                cb('Bounding Boxes', _showBoundingBoxes,
                                    (v) => _showBoundingBoxes = v),
                                cb('Labels', _showLabels,
                                    (v) => _showLabels = v),
                                cb('Per-class colors', _perClassColors,
                                    (v) => _perClassColors = v),
                              ],
                            ),
                            const SizedBox(height: 8),
                          ],
                        ),
                        ExpansionTile(
                          title: const Text('Colors',
                              style: TextStyle(fontWeight: FontWeight.bold)),
                          children: [
                            Padding(
                              padding: const EdgeInsets.symmetric(vertical: 8),
                              child: _ColorPickerButton(
                                label: 'Box color (when per-class off)',
                                color: _boundingBoxColor,
                                onColorChanged: (c) =>
                                    update(() => _boundingBoxColor = c),
                              ),
                            ),
                          ],
                        ),
                        ExpansionTile(
                          title: const Text('Sizes',
                              style: TextStyle(fontWeight: FontWeight.bold)),
                          children: [
                            sl('Box thickness', _boundingBoxThickness, 0.5,
                                10.0, (v) => _boundingBoxThickness = v),
                            sl('Label size', _labelFontSize, 8.0, 28.0,
                                (v) => _labelFontSize = v),
                            const SizedBox(height: 8),
                          ],
                        ),
                        ExpansionTile(
                          title: const Text('Detection',
                              style: TextStyle(fontWeight: FontWeight.bold)),
                          children: [
                            sl('Score threshold', _scoreThreshold, 0.0, 1.0,
                                (v) => _scoreThreshold = v,
                                rerun: true),
                            sl('Max results', _maxResults.toDouble(), 1.0, 30.0,
                                (v) => _maxResults = v.round(),
                                rerun: true),
                            const SizedBox(height: 8),
                          ],
                        ),
                        ExpansionTile(
                          title: const Text('Class filter',
                              style: TextStyle(fontWeight: FontWeight.bold)),
                          subtitle: Text(
                            _allowClasses.isEmpty
                                ? 'All classes'
                                : _allowClasses.join(', '),
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                          ),
                          children: [
                            Wrap(
                              spacing: 6,
                              runSpacing: 6,
                              children: [
                                for (final c in kFilterClasses)
                                  FilterChip(
                                    label: Text(c),
                                    selected: _allowClasses.contains(c),
                                    onSelected: (sel) => update(() {
                                      if (sel) {
                                        _allowClasses.add(c);
                                      } else {
                                        _allowClasses.remove(c);
                                      }
                                    }, rerun: true),
                                  ),
                              ],
                            ),
                            const SizedBox(height: 8),
                          ],
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            );
          },
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final bool hasImage = _imageBytes != null && _originalSize != null;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Still Image Detection'),
        actions: [
          IconButton(
            onPressed: _isLoading ? null : _pickAndRun,
            icon: const Icon(Icons.add_photo_alternate),
            tooltip: 'Pick Image',
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 4),
            child: DropdownButton<ObjectDetectionModel>(
              value: _model,
              underline: const SizedBox(),
              onChanged: _isLoading
                  ? null
                  : (v) {
                      if (v != null && v != _model) _switchModel(v);
                    },
              items: [
                for (final m in ObjectDetectionModel.values)
                  DropdownMenuItem(value: m, child: Text(modelLabel(m))),
              ],
            ),
          ),
          IconButton(
            onPressed: _showSettingsSheet,
            icon: const Icon(Icons.tune),
            tooltip: 'Settings',
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: Stack(
              children: [
                Center(
                  child: hasImage
                      ? LayoutBuilder(
                          builder: (context, constraints) {
                            final fitted = applyBoxFit(
                              BoxFit.contain,
                              _originalSize!,
                              Size(constraints.maxWidth, constraints.maxHeight),
                            );
                            final Size renderSize = fitted.destination;
                            final Rect imageRect = Alignment.center.inscribe(
                              renderSize,
                              Offset.zero &
                                  Size(constraints.maxWidth,
                                      constraints.maxHeight),
                            );
                            return Stack(
                              children: [
                                Positioned.fromRect(
                                  rect: imageRect,
                                  child: Image.memory(
                                    _imageBytes!,
                                    fit: BoxFit.fill,
                                    gaplessPlayback: true,
                                  ),
                                ),
                                Positioned.fromRect(
                                  rect: imageRect,
                                  child: CustomPaint(
                                    painter: DetectionsPainter(
                                      detections: _detections,
                                      imageRectOnCanvas: Rect.fromLTWH(0, 0,
                                          imageRect.width, imageRect.height),
                                      originalImageSize: _originalSize!,
                                      showBoundingBoxes: _showBoundingBoxes,
                                      showLabels: _showLabels,
                                      boundingBoxThickness:
                                          _boundingBoxThickness,
                                      labelFontSize: _labelFontSize,
                                      boundingBoxColor: _perClassColors
                                          ? null
                                          : _boundingBoxColor,
                                    ),
                                  ),
                                ),
                              ],
                            );
                          },
                        )
                      : _ScrollableCentered(
                          child: Column(
                            mainAxisSize: MainAxisSize.min,
                            children: [
                              Icon(Icons.add_photo_alternate,
                                  size: 80, color: Colors.grey[300]),
                              const SizedBox(height: 16),
                              Text('No image selected',
                                  style: TextStyle(
                                      fontSize: 18, color: Colors.grey[600])),
                              const SizedBox(height: 8),
                              Text('Tap a sample below or pick an image',
                                  style: TextStyle(
                                      fontSize: 14, color: Colors.grey[500])),
                            ],
                          ),
                        ),
                ),
                if (hasImage && _detectionTimeMs != null)
                  Positioned(
                    top: 12,
                    left: 12,
                    child: TimingBadge(
                      totalMs: _detectionTimeMs!,
                      detectionMs: _detectionTimeMs,
                    ),
                  ),
                if (_isLoading)
                  Container(
                    color: Colors.black26,
                    child: const Center(child: CircularProgressIndicator()),
                  ),
              ],
            ),
          ),
          const Divider(height: 1),
          SizedBox(
            height: 56,
            child: ListView.separated(
              scrollDirection: Axis.horizontal,
              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
              itemCount: _samples.length,
              separatorBuilder: (_, __) => const SizedBox(width: 8),
              itemBuilder: (context, i) => ActionChip(
                label: Text(_samples[i].$1),
                onPressed:
                    _isLoading ? null : () => _loadSample(_samples[i].$2),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ───────────────────────────── Live Camera ────────────────────────────────

class LiveCameraScreen extends StatefulWidget {
  const LiveCameraScreen({super.key});
  @override
  State<LiveCameraScreen> createState() => _LiveCameraScreenState();
}

class _LiveCameraScreenState extends State<LiveCameraScreen> {
  CameraController? _cameraController;
  List<CameraDescription> _availableCameras = const [];
  ObjectDetector? _detector;
  List<DetectedObject> _detections = const [];
  Size? _imageSize;
  int? _sensorOrientation;
  bool _isFrontCamera = false;
  bool _isSwitchingCamera = false;
  bool _isInitialized = false;
  bool _isImageStreamStarted = false;
  final FrameThrottle _throttle = FrameThrottle();
  final FpsCounter _fpsCounter = FpsCounter();
  int _fps = 0;
  int _detectionTimeMs = 0;

  DeviceOrientation _deviceOrientation = DeviceOrientation.portraitUp;
  StreamSubscription<AccelerometerEvent>? _accelerometerSub;

  ObjectDetectionModel _model = kDefaultModel;
  double _scoreThreshold = 0.4;
  bool _showLabels = true;

  @override
  void initState() {
    super.initState();
    _initCamera();
    if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
      _accelerometerSub = accelerometerEventStream().listen((event) {
        final next = event.x.abs() > event.y.abs()
            ? (event.x > 0
                ? DeviceOrientation.landscapeLeft
                : DeviceOrientation.landscapeRight)
            : (event.y > 0
                ? DeviceOrientation.portraitUp
                : DeviceOrientation.portraitDown);
        if (next == DeviceOrientation.portraitDown &&
            (_deviceOrientation == DeviceOrientation.landscapeLeft ||
                _deviceOrientation == DeviceOrientation.landscapeRight)) {
          return;
        }
        if (next != _deviceOrientation && mounted) {
          setState(() => _deviceOrientation = next);
        }
      });
    }
  }

  Future<void> _reinitDetector() async {
    final old = _detector;
    _detector = null;
    await old?.dispose();
    _detector = await ObjectDetector.create(model: _model);
  }

  Future<void> _initCamera() async {
    try {
      await _reinitDetector();

      final cameras = await availableCameras();
      if (cameras.isEmpty) {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('No cameras available')),
          );
        }
        return;
      }
      _availableCameras = cameras;

      final camera = cameras.firstWhere(
        (c) => c.lensDirection == CameraLensDirection.back,
        orElse: () => cameras.first,
      );
      await _startControllerFor(camera, markInitialized: true);
    } catch (e, st) {
      debugPrint('Camera init failed: $e\n$st');
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error initializing camera: $e')),
        );
      }
    }
  }

  Future<void> _startControllerFor(
    CameraDescription camera, {
    required bool markInitialized,
  }) async {
    final controller = CameraController(
      camera,
      ResolutionPreset.medium,
      enableAudio: false,
      imageFormatGroup: ImageFormatGroup.yuv420,
    );
    await controller.initialize();
    if (!mounted) {
      await controller.dispose();
      return;
    }
    setState(() {
      _cameraController = controller;
      if (markInitialized) _isInitialized = true;
      _sensorOrientation = controller.description.sensorOrientation;
      _isFrontCamera =
          controller.description.lensDirection == CameraLensDirection.front;
    });
    await controller.startImageStream(_processCameraImage);
    _isImageStreamStarted = true;
  }

  bool get _canSwitchCamera {
    if (kIsWeb) return false;
    if (!(Platform.isAndroid || Platform.isIOS)) return false;
    final hasFront = _availableCameras
        .any((c) => c.lensDirection == CameraLensDirection.front);
    final hasBack = _availableCameras
        .any((c) => c.lensDirection == CameraLensDirection.back);
    return hasFront && hasBack;
  }

  Future<void> _switchCamera() async {
    if (_isSwitchingCamera || !_canSwitchCamera) return;
    final target =
        _isFrontCamera ? CameraLensDirection.back : CameraLensDirection.front;
    final next = _availableCameras.firstWhere(
      (c) => c.lensDirection == target,
      orElse: () => _availableCameras.first,
    );
    final prev = _cameraController;
    setState(() {
      _isSwitchingCamera = true;
      _cameraController = null;
      _detections = const [];
      _imageSize = null;
    });
    try {
      if (prev != null) {
        if (_isImageStreamStarted) {
          try {
            await prev.stopImageStream();
          } catch (_) {}
          _isImageStreamStarted = false;
        }
        await prev.dispose();
      }
      await _startControllerFor(next, markInitialized: false);
    } catch (e) {
      debugPrint('Camera switch failed: $e');
    } finally {
      if (mounted) setState(() => _isSwitchingCamera = false);
    }
  }

  Future<void> _switchModel(ObjectDetectionModel m) async {
    if (m == _model) return;
    setState(() => _model = m);
    await _reinitDetector();
  }

  DeviceOrientation _effectiveDeviceOrientation(BuildContext context) {
    final controller = _cameraController;
    if (controller != null) return controller.value.deviceOrientation;
    return MediaQuery.of(context).orientation == Orientation.portrait
        ? DeviceOrientation.portraitUp
        : DeviceOrientation.landscapeLeft;
  }

  Future<void> _processCameraImage(CameraImage image) async {
    if (_fpsCounter.tick() && mounted) {
      setState(() => _fps = _fpsCounter.fps);
    }
    await _throttle.run(() async {
      try {
        final det = _detector;
        if (det == null || !det.isReady || !mounted) return;
        final sensor = _sensorOrientation;
        final CameraFrameRotation? rotation = sensor == null
            ? null
            : rotationForFrame(
                width: image.width,
                height: image.height,
                sensorOrientation: sensor,
                isFrontCamera: _isFrontCamera,
                deviceOrientation: _effectiveDeviceOrientation(context),
              );
        const int maxDim = 640;
        final Size size = detectionSize(
          width: image.width,
          height: image.height,
          rotation: rotation,
          maxDim: maxDim,
        );
        final sw = Stopwatch()..start();
        final results = await det.detectFromCameraImage(
          image,
          rotation: rotation,
          maxDim: maxDim,
          options: ObjectDetectorOptions(scoreThreshold: _scoreThreshold),
        );
        sw.stop();
        if (mounted) {
          setState(() {
            _detections = results;
            _imageSize = size;
            _detectionTimeMs = sw.elapsedMilliseconds;
          });
        }
      } catch (_) {
        // Keep the stream alive on transient errors.
      }
    });
  }

  @override
  void dispose() {
    _accelerometerSub?.cancel();
    if (_isImageStreamStarted) _cameraController?.stopImageStream();
    _cameraController?.dispose();
    _detector?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (!_isInitialized || _cameraController == null) {
      return Scaffold(
        appBar: AppBar(title: const Text('Live Camera Detection')),
        body: const Center(child: CircularProgressIndicator()),
      );
    }

    final cameraAspectRatio = _cameraController!.value.aspectRatio;
    final effectiveOrientation = _effectiveDeviceOrientation(context);
    final bool isPortrait =
        effectiveOrientation == DeviceOrientation.portraitUp ||
            effectiveOrientation == DeviceOrientation.portraitDown;
    final double displayAspectRatio =
        isPortrait ? 1.0 / cameraAspectRatio : cameraAspectRatio;
    final int turns = barQuarterTurns(_deviceOrientation);
    final bool mirrorOverlay =
        (Platform.isAndroid && _isFrontCamera) || Platform.isWindows;

    return Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: [
          ObjectDetectionCameraOverlay(
            cameraPreview: CameraPreview(_cameraController!),
            displayAspectRatio: displayAspectRatio,
            mirrorHorizontally: mirrorOverlay,
            detections: _detections,
            imageSize: _imageSize,
            showLabels: _showLabels,
          ),
          _positionedTopBar(turns),
        ],
      ),
    );
  }

  Widget _positionedTopBar(int turns) {
    final bar = _buildCameraTopBar();
    final padding = MediaQuery.of(context).padding;
    if (turns == 0) {
      return Positioned(
          top: padding.top,
          left: padding.left,
          right: padding.right,
          child: bar);
    }
    return Positioned(
      top: padding.top,
      bottom: padding.bottom,
      left: turns == 3 ? padding.left : null,
      right: turns == 1 ? padding.right : null,
      width: kToolbarHeight,
      child: RotatedBox(quarterTurns: turns, child: bar),
    );
  }

  Widget _buildCameraTopBar() {
    final canPop = Navigator.of(context).canPop();
    return Material(
      color: Colors.black.withAlpha(179),
      elevation: 4,
      child: SizedBox(
        height: kToolbarHeight,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 4),
          child: Row(
            children: [
              if (canPop)
                IconButton(
                  tooltip: 'Back',
                  color: Colors.white,
                  icon: const Icon(Icons.arrow_back),
                  onPressed: () => Navigator.of(context).maybePop(),
                ),
              const SizedBox(width: 4),
              SizedBox(
                width: 66,
                child: Text('FPS: $_fps',
                    style: const TextStyle(color: Colors.white, fontSize: 14)),
              ),
              const Text(' | ',
                  style: TextStyle(color: Colors.white, fontSize: 14)),
              SizedBox(
                width: 64,
                child: Text('${_detectionTimeMs}ms',
                    style: const TextStyle(color: Colors.white, fontSize: 14)),
              ),
              const Spacer(),
              if (_canSwitchCamera)
                IconButton(
                  tooltip: 'Switch camera',
                  color: Colors.white,
                  icon: Icon(Platform.isIOS
                      ? Icons.flip_camera_ios
                      : Icons.flip_camera_android),
                  onPressed: _isSwitchingCamera ? null : _switchCamera,
                ),
              PopupMenuButton<void>(
                tooltip: 'Settings',
                icon: const Icon(Icons.settings, color: Colors.white),
                color: Colors.blueGrey[900],
                itemBuilder: (context) => [
                  PopupMenuItem<void>(
                    enabled: false,
                    padding: EdgeInsets.zero,
                    child: StatefulBuilder(
                      builder: (context, setMenuState) =>
                          _buildSettingsMenuContent(setMenuState),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildSettingsMenuContent(StateSetter setMenuState) {
    void update(VoidCallback fn) {
      setState(fn);
      setMenuState(() {});
    }

    const sectionLabelStyle = TextStyle(
      color: Colors.white60,
      fontSize: 10,
      fontWeight: FontWeight.w600,
      letterSpacing: 1.2,
    );

    Widget chip(ObjectDetectionModel v, String label) {
      final selected = _model == v;
      return GestureDetector(
        onTap: () async {
          if (v == _model) return;
          await _switchModel(v);
          if (mounted) setMenuState(() {});
        },
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
          decoration: BoxDecoration(
            color: selected ? Colors.blue : Colors.white12,
            borderRadius: BorderRadius.circular(12),
          ),
          child: Text(label,
              style: TextStyle(
                color: selected ? Colors.white : Colors.white70,
                fontSize: 12,
                fontWeight: selected ? FontWeight.bold : FontWeight.normal,
              )),
        ),
      );
    }

    return SizedBox(
      width: 260,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('MODEL', style: sectionLabelStyle),
            const SizedBox(height: 8),
            Wrap(
              spacing: 6,
              runSpacing: 6,
              children: [
                chip(ObjectDetectionModel.efficientDetLite0, 'Lite0 (fast)'),
                chip(
                    ObjectDetectionModel.efficientDetLite2, 'Lite2 (accurate)'),
              ],
            ),
            const Divider(color: Colors.white24, height: 24),
            const Text('SCORE THRESHOLD', style: sectionLabelStyle),
            Slider(
              value: _scoreThreshold,
              min: 0.0,
              max: 1.0,
              label: _scoreThreshold.toStringAsFixed(2),
              onChanged: (v) => update(() => _scoreThreshold = v),
            ),
            const Divider(color: Colors.white24, height: 8),
            Row(
              children: [
                const Expanded(
                  child: Text('Labels',
                      style: TextStyle(color: Colors.white70, fontSize: 14)),
                ),
                Switch(
                  value: _showLabels,
                  activeTrackColor: Colors.blue,
                  onChanged: (v) => update(() => _showLabels = v),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

// ───────────────────────────── Video File ─────────────────────────────────

class VideoFileScreen extends StatefulWidget {
  const VideoFileScreen({super.key});
  @override
  State<VideoFileScreen> createState() => _VideoFileScreenState();
}

class _VideoFileScreenState extends State<VideoFileScreen> {
  ObjectDetector? _detector;
  ObjectDetectionModel _model = kDefaultModel;
  bool _isInitialized = false;
  bool _isProcessing = false;
  bool _cancelRequested = false;
  String? _errorMessage;
  String? _statusMessage;

  String? _outputPath;
  int _totalFrames = 0;
  int _processedFrames = 0;
  double _videoFps = 0;
  int _videoWidth = 0;
  int _videoHeight = 0;
  Duration _elapsed = Duration.zero;
  final Stopwatch _wallClock = Stopwatch();

  VideoPlayerController? _playerController;
  bool _playerReady = false;

  bool _smoothingEnabled = true;
  final ObjectSmoother _smoother = ObjectSmoother(enabled: true);

  // Paint options.
  bool _showBoundingBoxes = true;
  bool _showLabels = true;
  bool _perClassColors = true;
  Color _boundingBoxColor = const Color(0xFF00FFCC);
  // Multiplier on the resolution-derived box thickness (1.0 = default).
  double _boundingBoxThickness = 1.0;
  double _scoreThreshold = 0.4;
  int _maxResults = 20;

  bool get _supportsInAppPlayer {
    if (kIsWeb) return true;
    return Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
  }

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

  Future<void> _initDetector() async {
    try {
      final detector = await ObjectDetector.create(model: _model);
      if (!mounted) {
        await detector.dispose();
        return;
      }
      setState(() {
        _detector = detector;
        _isInitialized = true;
      });
    } catch (e) {
      if (mounted) {
        setState(() => _errorMessage = 'Failed to init detector: $e');
      }
    }
  }

  Future<void> _switchModel(ObjectDetectionModel m) async {
    if (m == _model || _isProcessing) return;
    setState(() {
      _model = m;
      _isInitialized = false;
    });
    final old = _detector;
    _detector = null;
    await old?.dispose();
    await _initDetector();
  }

  @override
  void dispose() {
    _cancelRequested = true;
    _detector?.dispose();
    _playerController?.dispose();
    super.dispose();
  }

  Future<void> _disposePlayer() async {
    final c = _playerController;
    _playerController = null;
    _playerReady = false;
    await c?.dispose();
  }

  Future<void> _initPlayerForOutput(String path) async {
    await _disposePlayer();
    if (!_supportsInAppPlayer) return;
    final controller = VideoPlayerController.file(File(path));
    _playerController = controller;
    try {
      await controller.initialize();
      await controller.setLooping(true);
      if (!mounted) {
        await controller.dispose();
        _playerController = null;
        return;
      }
      setState(() => _playerReady = true);
      await controller.play();
    } catch (e) {
      debugPrint('Could not load output video: $e');
    }
  }

  Future<void> _pickVideo() async {
    const typeGroup = XTypeGroup(
      label: 'Videos',
      extensions: ['mp4', 'mov', 'm4v'],
    );
    final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]);
    if (file == null) return;
    await _processVideo(file.path);
  }

  Future<void> _processVideo(String path) async {
    final inputFile = File(path);
    if (!await inputFile.exists()) {
      setState(() => _errorMessage = 'File does not exist: $path');
      return;
    }

    final docs = await getApplicationDocumentsDirectory();
    final outName = 'object_${DateTime.now().millisecondsSinceEpoch}.mp4';
    final outPath = '${docs.path}/$outName';

    await _disposePlayer();
    setState(() {
      _outputPath = outPath;
      _processedFrames = 0;
      _totalFrames = 0;
      _isProcessing = true;
      _cancelRequested = false;
      _errorMessage = null;
      _statusMessage = 'Processing...';
      _elapsed = Duration.zero;
    });
    _wallClock
      ..reset()
      ..start();

    final options = ObjectDetectorOptions(
      scoreThreshold: _scoreThreshold,
      maxResults: _maxResults,
    );

    try {
      final result = await processVideoFile(
        detector: _detector!,
        inputPath: path,
        outputPath: outPath,
        options: options,
        smoother: _smoother,
        showBoxes: _showBoundingBoxes,
        showLabels: _showLabels,
        perClassColors: _perClassColors,
        boxColor: _boundingBoxColor,
        thicknessScale: _boundingBoxThickness,
        onProgress: (processed, total) {
          if (!mounted) return;
          setState(() {
            _processedFrames = processed;
            _totalFrames = total;
            _elapsed = _wallClock.elapsed;
          });
        },
        shouldCancel: () => !mounted || _cancelRequested,
      );
      if (mounted) {
        setState(() {
          _processedFrames = result.frames;
          _totalFrames = result.total;
          _videoFps = result.fps;
          _videoWidth = result.width;
          _videoHeight = result.height;
          _elapsed = _wallClock.elapsed;
          _statusMessage = _cancelRequested
              ? 'Cancelled after ${result.frames} frames.'
              : 'Done. Wrote ${result.frames} frames.';
        });
      }
    } catch (e) {
      String hint = '';
      if (Platform.isLinux) {
        hint = '\n\nLinux requires GStreamer plugins. Try:\n'
            '  sudo apt install gstreamer1.0-libav '
            'gstreamer1.0-plugins-good gstreamer1.0-plugins-bad';
      }
      if (mounted) {
        setState(() => _errorMessage =
            'Could not process video: $e\n\nThe format may be unsupported by '
                'the OS video backend, or the "avc1" (H.264) writer may be '
                'unavailable.$hint');
      }
    } finally {
      _wallClock.stop();
      if (mounted) setState(() => _isProcessing = false);
      if (mounted && !_cancelRequested && _outputPath != null) {
        await _initPlayerForOutput(_outputPath!);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Video File Detection'),
        actions: [
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 4),
            child: DropdownButton<ObjectDetectionModel>(
              value: _model,
              underline: const SizedBox(),
              onChanged: _isProcessing
                  ? null
                  : (v) {
                      if (v != null) _switchModel(v);
                    },
              items: [
                for (final m in ObjectDetectionModel.values)
                  DropdownMenuItem(value: m, child: Text(modelLabel(m))),
              ],
            ),
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          if (!_isInitialized)
            const Padding(
              padding: EdgeInsets.symmetric(vertical: 24),
              child: Center(child: CircularProgressIndicator()),
            ),
          if (_errorMessage != null)
            Card(
              color: Colors.red[50],
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Text(_errorMessage!,
                    style: const TextStyle(color: Colors.red)),
              ),
            ),
          _buildSettingsCard(),
          const SizedBox(height: 12),
          if (!_isProcessing)
            ElevatedButton.icon(
              onPressed: _isInitialized ? _pickVideo : null,
              icon: const Icon(Icons.video_library),
              label: const Text('Pick a video (MP4)'),
            ),
          if (_isProcessing) _buildProgress(),
          const SizedBox(height: 16),
          if (_playerReady && _playerController != null && !_isProcessing)
            VideoResultCard(
              statusMessage: _statusMessage ?? 'Done',
              summary: _summaryText(),
              preview: _OutputVideoPlayer(controller: _playerController!),
            )
          else if (_statusMessage != null && !_isProcessing)
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(_statusMessage!,
                        style: const TextStyle(fontWeight: FontWeight.w500)),
                    const SizedBox(height: 4),
                    Text(_summaryText()),
                    if (_outputPath != null) ...[
                      const SizedBox(height: 8),
                      SelectableText('Output: $_outputPath',
                          style: const TextStyle(fontSize: 12)),
                    ],
                  ],
                ),
              ),
            ),
        ],
      ),
    );
  }

  String _summaryText() {
    final secs = _elapsed.inMilliseconds / 1000.0;
    final procFps = secs > 0 ? (_processedFrames / secs) : 0.0;
    return '$_processedFrames frames  •  '
        '${_videoWidth}x$_videoHeight @ ${_videoFps.toStringAsFixed(0)} fps  •  '
        '${secs.toStringAsFixed(1)}s  •  '
        '${procFps.toStringAsFixed(1)} fps processing';
  }

  Widget _buildSettingsCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Options',
                style: TextStyle(fontWeight: FontWeight.bold)),
            const SizedBox(height: 4),
            Wrap(
              spacing: 12,
              runSpacing: 4,
              crossAxisAlignment: WrapCrossAlignment.center,
              children: [
                CompactCheckbox(
                  label: 'Boxes',
                  value: _showBoundingBoxes,
                  onChanged: (v) =>
                      setState(() => _showBoundingBoxes = v ?? true),
                ),
                CompactCheckbox(
                  label: 'Labels',
                  value: _showLabels,
                  onChanged: (v) => setState(() => _showLabels = v ?? true),
                ),
                CompactCheckbox(
                  label: 'Per-class colors',
                  value: _perClassColors,
                  onChanged: (v) => setState(() => _perClassColors = v ?? true),
                ),
                CompactCheckbox(
                  label: 'Smoothing (tracking)',
                  value: _smoothingEnabled,
                  onChanged: (v) => setState(() {
                    _smoothingEnabled = v ?? true;
                    _smoother.enabled = _smoothingEnabled;
                  }),
                ),
                _ColorPickerButton(
                  label: 'Box color',
                  color: _boundingBoxColor,
                  onColorChanged: (c) => setState(() => _boundingBoxColor = c),
                ),
              ],
            ),
            CompactSlider(
              label: 'Box thickness x (scales with resolution)',
              value: _boundingBoxThickness,
              min: 0.5,
              max: 3.0,
              onChanged: (v) => setState(() => _boundingBoxThickness = v),
            ),
            CompactSlider(
              label: 'Score threshold',
              value: _scoreThreshold,
              min: 0.0,
              max: 1.0,
              onChanged: (v) => setState(() => _scoreThreshold = v),
            ),
            CompactSlider(
              label: 'Max results',
              value: _maxResults.toDouble(),
              min: 1.0,
              max: 30.0,
              onChanged: (v) => setState(() => _maxResults = v.round()),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildProgress() {
    final double pct = _totalFrames > 0
        ? (_processedFrames / _totalFrames).clamp(0.0, 1.0)
        : 0;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text('Processing frame $_processedFrames'
                '${_totalFrames > 0 ? ' / $_totalFrames' : ''}'),
            const SizedBox(height: 8),
            LinearProgressIndicator(value: _totalFrames > 0 ? pct : null),
            const SizedBox(height: 12),
            Align(
              alignment: Alignment.centerRight,
              child: OutlinedButton.icon(
                onPressed: () => setState(() => _cancelRequested = true),
                icon: const Icon(Icons.stop),
                label: const Text('Cancel'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class VideoResultCard extends StatelessWidget {
  final String statusMessage;
  final String summary;
  final Widget preview;

  const VideoResultCard({
    super.key,
    required this.statusMessage,
    required this.summary,
    required this.preview,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                const Icon(Icons.check_circle, color: Colors.green),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(statusMessage,
                      style: const TextStyle(fontWeight: FontWeight.w500)),
                ),
              ],
            ),
            const SizedBox(height: 8),
            Text(summary),
            const SizedBox(height: 12),
            preview,
          ],
        ),
      ),
    );
  }
}

class VideoPlayerChrome extends StatelessWidget {
  final double aspectRatio;
  final Widget video;
  final Widget progress;
  final bool isPlaying;
  final String positionLabel;
  final VoidCallback onTogglePlay;

  const VideoPlayerChrome({
    super.key,
    required this.aspectRatio,
    required this.video,
    required this.progress,
    required this.isPlaying,
    required this.positionLabel,
    required this.onTogglePlay,
  });

  @override
  Widget build(BuildContext context) {
    final double maxPreviewHeight =
        math.max(120.0, MediaQuery.sizeOf(context).height * 0.45);
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Align(
          alignment: Alignment.centerLeft,
          child: ConstrainedBox(
            constraints: BoxConstraints(maxHeight: maxPreviewHeight),
            child: ClipRRect(
              borderRadius: BorderRadius.circular(8),
              child: AspectRatio(
                aspectRatio: aspectRatio,
                child: Stack(
                  fit: StackFit.expand,
                  children: [Container(color: Colors.black), video],
                ),
              ),
            ),
          ),
        ),
        const SizedBox(height: 8),
        LayoutBuilder(
          builder: (context, constraints) {
            final bool showTime = constraints.maxWidth >= 180;
            return Row(
              children: [
                IconButton(
                  icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
                  onPressed: onTogglePlay,
                ),
                Expanded(child: progress),
                if (showTime) ...[
                  const SizedBox(width: 8),
                  Text(positionLabel),
                ],
              ],
            );
          },
        ),
      ],
    );
  }
}

class _OutputVideoPlayer extends StatefulWidget {
  final VideoPlayerController controller;
  const _OutputVideoPlayer({required this.controller});

  @override
  State<_OutputVideoPlayer> createState() => _OutputVideoPlayerState();
}

class _OutputVideoPlayerState extends State<_OutputVideoPlayer> {
  void _onTick() {
    if (mounted) setState(() {});
  }

  @override
  void initState() {
    super.initState();
    widget.controller.addListener(_onTick);
  }

  @override
  void didUpdateWidget(covariant _OutputVideoPlayer oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.controller != widget.controller) {
      oldWidget.controller.removeListener(_onTick);
      widget.controller.addListener(_onTick);
    }
  }

  @override
  void dispose() {
    widget.controller.removeListener(_onTick);
    super.dispose();
  }

  String _fmt(Duration d) {
    final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
    final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
    return '$m:$s';
  }

  @override
  Widget build(BuildContext context) {
    final c = widget.controller;
    final value = c.value;
    return VideoPlayerChrome(
      aspectRatio: value.aspectRatio == 0 ? 16 / 9 : value.aspectRatio,
      video: VideoPlayer(c),
      progress: VideoProgressIndicator(
        c,
        allowScrubbing: true,
        padding: const EdgeInsets.symmetric(vertical: 12),
      ),
      isPlaying: value.isPlaying,
      positionLabel: '${_fmt(value.position)} / ${_fmt(value.duration)}',
      onTogglePlay: () => value.isPlaying ? c.pause() : c.play(),
    );
  }
}

// ──────────────────────────── Shared widgets ──────────────────────────────

class _ScrollableCentered extends StatelessWidget {
  final Widget child;
  const _ScrollableCentered({required this.child});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        return SingleChildScrollView(
          child: ConstrainedBox(
            constraints: BoxConstraints(minHeight: constraints.maxHeight),
            child: Center(child: child),
          ),
        );
      },
    );
  }
}

/// A small color swatch that opens a color picker dialog on tap.
class _ColorPickerButton extends StatelessWidget {
  final String label;
  final Color color;
  final ValueChanged<Color> onColorChanged;

  const _ColorPickerButton({
    required this.label,
    required this.color,
    required this.onColorChanged,
  });

  void _pick(BuildContext context) {
    Color temp = color;
    showDialog<void>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Text('Pick $label'),
        content: SingleChildScrollView(
          child: ColorPicker(
            pickerColor: color,
            onColorChanged: (c) => temp = c,
            enableAlpha: false,
            portraitOnly: true,
          ),
        ),
        actions: [
          TextButton(
              onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
          TextButton(
            onPressed: () {
              onColorChanged(temp);
              Navigator.pop(ctx);
            },
            child: const Text('Select'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: () => _pick(context),
      borderRadius: BorderRadius.circular(6),
      child: Padding(
        padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Container(
              width: 44,
              height: 26,
              decoration: BoxDecoration(
                color: color,
                borderRadius: BorderRadius.circular(6),
                border: Border.all(color: Colors.black26),
              ),
            ),
            const SizedBox(height: 4),
            Text(label, style: const TextStyle(fontSize: 11)),
          ],
        ),
      ),
    );
  }
}
3
likes
160
points
558
downloads

Documentation

API reference

Publisher

verified publisherhugo.ml

Weekly Downloads

On-device object detection using MediaPipe EfficientDet-Lite models in LiteRT (formerly TensorFlow Lite) format. 80 COCO classes, fully offline.

Repository (GitHub)
View/report issues

Topics

#litert #tflite #object-detection #computer-vision #on-device-ml

License

Apache-2.0 (license)

Dependencies

flutter, flutter_litert, meta, opencv_dart

More

Packages that depend on object_detection

Packages that implement object_detection