fast_paddle_detection 0.0.3 copy "fast_paddle_detection: ^0.0.3" to clipboard
fast_paddle_detection: ^0.0.3 copied to clipboard

PlatformAndroid

High-performance offline object detection plugin using PP-PicoDet + NCNN with native camera, anti-spoof, and GPU acceleration.

example/lib/main.dart

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

import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:fast_paddle_detection/paddle_detection.dart';
import 'package:permission_handler/permission_handler.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'PicoDet Demo',
    debugShowCheckedModeBanner: false,
    theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
    darkTheme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true, brightness: Brightness.dark),
    home: const HomePage(),
  );
}

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

class _HomePageState extends State<HomePage> {
  final _detector = PaddleDetection();
  bool _modelLoaded = false;
  bool _loading = false;
  String _status = 'Loading model...';
  double _threshold = 0.8;
  int _numClass = 0;
  String? _customParamPath, _customBinPath, _customLabelsPath;
  bool get _useCustom => _customParamPath != null && _customBinPath != null;
  int _tabIndex = 0;
  bool _gpuAvailable = false;
  bool _useGpu = false;
  bool _antiSpoof = false; // default CPU

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

  Future<void> _checkGpuAndLoad() async {
    _gpuAvailable = await _detector.hasGpu();
    _loadModel();
  }

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

  Future<void> _loadModel() async {
    setState(() {
      _loading = true;
      _status = 'Loading...';
    });
    try {
      final cpuGpu = _useGpu ? 1 : 0;
      final info = _useCustom
          ? await _detector.loadModelFromFile(paramPath: _customParamPath!, binPath: _customBinPath!, cpuGpu: cpuGpu)
          : await _detector.loadModel(
              paramName: 'model_no_post_pro.ncnn.param',
              binName: 'model_no_post_pro.ncnn.bin',
              cpuGpu: cpuGpu,
            );
      if (info.success) {
        await _detector.setThreshold(threshold: _threshold);
        // Load labels: custom file if picked, otherwise try asset
        if (_customLabelsPath != null) {
          try {
            await _detector.loadLabelsFromFile(_customLabelsPath!);
          } catch (_) {}
        } else {
          try {
            await _detector.loadLabelsFromAsset('labels.txt');
          } catch (_) {}
        }
      }
      final backend = _useGpu ? 'GPU' : 'CPU';
      setState(() {
        _modelLoaded = info.success;
        _numClass = info.numClass;
        _loading = false;
        _status = info.success ? 'Model loaded ✓ (${info.numClass} cls, $backend)' : 'Load failed';
      });
    } catch (e) {
      setState(() {
        _loading = false;
        _status = 'Error: $e';
      });
    }
  }

  Future<void> _applyThreshold() async {
    if (_modelLoaded) await _detector.setThreshold(threshold: _threshold);
  }

  Future<void> _pickParam() async {
    final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: false);
    if (result == null || result.files.isEmpty || result.files.first.path == null) return;
    setState(() {
      _customParamPath = result.files.first.path;
      _modelLoaded = false;
    });
    _snack('Param: ${result.files.first.name}');
  }

  Future<void> _pickBin() async {
    final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: false);
    if (result == null || result.files.isEmpty || result.files.first.path == null) return;
    setState(() {
      _customBinPath = result.files.first.path;
      _modelLoaded = false;
    });
    _snack('Bin: ${result.files.first.name}');
  }

  Future<void> _reloadCustomModel() async {
    if (_customParamPath == null || _customBinPath == null) {
      _snack('Pick both .param and .bin first');
      return;
    }
    _loadModel();
  }

  Future<void> _pickLabels() async {
    final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: false);
    if (result == null || result.files.isEmpty || result.files.first.path == null) return;
    _customLabelsPath = result.files.first.path;
    try {
      final labels = await _detector.loadLabelsFromFile(_customLabelsPath!);
      _snack('Labels loaded: ${labels.length} (${labels.take(3).join(", ")}...)');
    } catch (e) {
      _snack('Failed: $e');
      _customLabelsPath = null;
    }
  }

  void _useBundled() {
    setState(() {
      _customParamPath = null;
      _customBinPath = null;
      _customLabelsPath = null;
      _modelLoaded = false;
    });
    _loadModel();
  }

  void _snack(String m) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m)));

  void _openSettings() => showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    builder: (_) => _SettingsSheet(
      threshold: _threshold,
      useCustom: _useCustom,
      customParam: _customParamPath,
      customBin: _customBinPath,
      modelLoaded: _modelLoaded,
      loading: _loading,
      numClass: _numClass,
      gpuAvailable: _gpuAvailable,
      useGpu: _useGpu,
      antiSpoof: _antiSpoof,
      onThresholdChanged: (v) {
        setState(() => _threshold = v);
        _applyThreshold();
      },
      onGpuChanged: (v) {
        setState(() => _useGpu = v);
        _loadModel(); // reload with new backend
      },
      onAntiSpoofChanged: (v) {
        setState(() => _antiSpoof = v);
        _detector.setAntiSpoof(enabled: v);
      },
      onPickModel: _pickParam,
      onPickBin: _pickBin,
      onPickLabels: _pickLabels,
      onUseBundled: _useBundled,
      onReload: () {
        Navigator.pop(context);
        _reloadCustomModel();
      },
    ),
  );

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(
      title: const Text('PicoDet'),
      centerTitle: true,
      actions: [IconButton(icon: const Icon(Icons.settings), onPressed: _openSettings)],
      bottom: PreferredSize(
        preferredSize: const Size.fromHeight(22),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              _modelLoaded ? Icons.check_circle : Icons.info_outline,
              size: 14,
              color: _modelLoaded ? Colors.green : Colors.orange,
            ),
            const SizedBox(width: 6),
            Text(_status, style: Theme.of(context).textTheme.bodySmall),
          ],
        ),
      ),
    ),
    body: IndexedStack(
      index: _tabIndex,
      children: [
        _CameraTab(detector: _detector, modelLoaded: _modelLoaded),
        _GalleryTab(detector: _detector, modelLoaded: _modelLoaded),
      ],
    ),
    bottomNavigationBar: NavigationBar(
      selectedIndex: _tabIndex,
      onDestinationSelected: (i) => setState(() => _tabIndex = i),
      destinations: const [
        NavigationDestination(icon: Icon(Icons.videocam), label: 'Camera'),
        NavigationDestination(icon: Icon(Icons.photo_library), label: 'Gallery'),
      ],
    ),
  );
}

// =============================================================================
// Camera Tab
// =============================================================================
class _CameraTab extends StatefulWidget {
  final PaddleDetection detector;
  final bool modelLoaded;
  const _CameraTab({required this.detector, required this.modelLoaded});
  @override
  State<_CameraTab> createState() => _CameraTabState();
}

class _CameraTabState extends State<_CameraTab> {
  int? _textureId;
  int _previewW = 0, _previewH = 0;
  List<DetectionResult> _detections = [];
  StreamSubscription? _sub;
  bool _started = false;
  bool _flashOn = false;
  bool _capturing = false;
  int _fps = 0;
  int _frameCount = 0;
  int _inferenceMs = 0;
  int _deviceRotation = 0;
  Timer? _fpsTimer;

  /// Convert device rotation degrees to RotatedBox quarter turns.
  /// deviceRotation: 0=portrait, 90=landscape-left, 180=upside-down, 270=landscape-right
  int get _quarterTurns {
    switch (_deviceRotation) {
      case 90:
        return 1; // landscape left
      case 180:
        return 2; // upside down
      case 270:
        return 3; // landscape right
      default:
        return 0; // portrait
    }
  }

  @override
  void didUpdateWidget(covariant _CameraTab old) {
    super.didUpdateWidget(old);
  }

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

  Future<void> _start() async {
    if (_started) return;
    final status = await Permission.camera.request();
    if (!status.isGranted) return;
    try {
      final info = await widget.detector.startCamera();
      _sub = widget.detector.detectionStream.listen((event) {
        if (mounted) {
          _frameCount++;
          setState(() {
            _detections = event.detections;
            _inferenceMs = event.inferenceMs;
            _deviceRotation = event.deviceRotation;
            if (event.imageWidth > 0 && event.imageHeight > 0) {
              _previewW = event.imageWidth;
              _previewH = event.imageHeight;
            }
          });
        }
      });
      _fpsTimer = Timer.periodic(const Duration(seconds: 1), (_) {
        if (mounted)
          setState(() {
            _fps = _frameCount;
            _frameCount = 0;
          });
      });
      if (mounted)
        setState(() {
          _textureId = info.textureId;
          _previewW = info.previewWidth;
          _previewH = info.previewHeight;
          _started = true;
        });
    } catch (e) {
      debugPrint('Camera error: $e');
    }
  }

  Future<void> _stop() async {
    _sub?.cancel();
    _sub = null;
    _fpsTimer?.cancel();
    _fpsTimer = null;
    await widget.detector.stopCamera();
    if (mounted)
      setState(() {
        _textureId = null;
        _started = false;
        _detections = [];
        _flashOn = false;
        _fps = 0;
        _frameCount = 0;
      });
  }

  Future<void> _toggleFlash() async {
    final on = await widget.detector.toggleFlash(enable: !_flashOn);
    setState(() => _flashOn = on);
  }

  Future<void> _capture() async {
    if (_capturing) return;
    setState(() => _capturing = true);
    try {
      // Save to app's external files directory
      final dir = Directory('/storage/emulated/0/DCIM/PicoDet');
      final result = await widget.detector.capturePhoto(folder: dir.path, prefix: 'det');
      if (mounted) {
        _showCaptureResult(result);
      }
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Capture error: $e')));
    }
    setState(() => _capturing = false);
  }

  void _showCaptureResult(CaptureResult result) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      builder: (_) => DraggableScrollableSheet(
        expand: false,
        initialChildSize: 0.75,
        minChildSize: 0.4,
        maxChildSize: 0.95,
        builder: (ctx, scroll) => ListView(
          controller: scroll,
          padding: const EdgeInsets.all(16),
          children: [
            Center(
              child: Container(
                width: 40,
                height: 4,
                margin: const EdgeInsets.only(bottom: 12),
                decoration: BoxDecoration(color: Colors.grey[400], borderRadius: BorderRadius.circular(2)),
              ),
            ),
            const Text('Capture Result', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 12),
            // Two images side by side
            Row(
              children: [
                Expanded(
                  child: Column(
                    children: [
                      const Text('With BBox', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
                      const SizedBox(height: 4),
                      GestureDetector(
                        onTap: () => _showFullImage(ctx, result.annotatedPath),
                        child: ClipRRect(
                          borderRadius: BorderRadius.circular(8),
                          child: Image.file(File(result.annotatedPath), fit: BoxFit.contain, height: 180),
                        ),
                      ),
                    ],
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: Column(
                    children: [
                      const Text('Clean', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
                      const SizedBox(height: 4),
                      GestureDetector(
                        onTap: () => _showFullImage(ctx, result.cleanPath),
                        child: ClipRRect(
                          borderRadius: BorderRadius.circular(8),
                          child: Image.file(File(result.cleanPath), fit: BoxFit.contain, height: 180),
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
            const SizedBox(height: 4),
            const Text(
              'Tap image for full screen',
              style: TextStyle(fontSize: 11, color: Colors.grey),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 12),
            Text('Detections: ${result.detections.length}', style: const TextStyle(fontWeight: FontWeight.bold)),
            ...result.detections.map(
              (d) => ListTile(
                dense: true,
                leading: CircleAvatar(
                  radius: 12,
                  backgroundColor: _Cls.color(d.label),
                  child: Text(
                    _Cls.name(d.label),
                    style: const TextStyle(fontSize: 8, color: Colors.white, fontWeight: FontWeight.bold),
                  ),
                ),
                title: Text('${_Cls.name(d.label)} — ${(d.probability * 100).toStringAsFixed(1)}%'),
                subtitle: Text(
                  '${d.x.toStringAsFixed(0)},${d.y.toStringAsFixed(0)} ${d.width.toStringAsFixed(0)}×${d.height.toStringAsFixed(0)}',
                ),
              ),
            ),
            const Divider(),
            Text('Clean: ${result.cleanPath}', style: const TextStyle(fontSize: 10, fontFamily: 'monospace')),
            Text('Bbox:  ${result.annotatedPath}', style: const TextStyle(fontSize: 10, fontFamily: 'monospace')),
          ],
        ),
      ),
    );
  }

  void _showFullImage(BuildContext ctx, String path) {
    Navigator.of(ctx).push(
      MaterialPageRoute(
        builder: (_) => Scaffold(
          backgroundColor: Colors.black,
          appBar: AppBar(backgroundColor: Colors.black, foregroundColor: Colors.white, elevation: 0),
          body: Center(
            child: InteractiveViewer(child: Image.file(File(path), fit: BoxFit.contain)),
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _sub?.cancel();
    _fpsTimer?.cancel();
    widget.detector.stopCamera();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (!widget.modelLoaded)
      return const Center(
        child: Text('Loading model...', style: TextStyle(color: Colors.grey, fontSize: 16)),
      );

    // Camera not started — show start button
    if (!_started || _textureId == null) {
      return Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.videocam_off, size: 64, color: Colors.grey[400]),
            const SizedBox(height: 16),
            FilledButton.icon(
              onPressed: _start,
              icon: const Icon(Icons.play_arrow),
              label: const Text('Start Detection'),
            ),
          ],
        ),
      );
    }

    return Stack(
      fit: StackFit.expand,
      children: [
        // Rotate preview to match physical device orientation
        Center(
          child: RotatedBox(
            quarterTurns: _quarterTurns,
            child: FittedBox(
              fit: BoxFit.cover,
              child: SizedBox(
                width: _previewW.toDouble(),
                height: _previewH.toDouble(),
                child: Texture(textureId: _textureId!),
              ),
            ),
          ),
        ),
        // Top info (FPS only in debug mode — this is Flutter overlay, not drawn on image)
        if (kDebugMode)
          Positioned(
            top: 8,
            left: 8,
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
              decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(8)),
              child: Text(
                '$_fps FPS • ${_inferenceMs}ms • ${_detections.length} obj',
                style: const TextStyle(color: Colors.white, fontSize: 12),
              ),
            ),
          ),
        // Bottom controls
        Positioned(
          bottom: 16,
          left: 0,
          right: 0,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              // Flash
              FloatingActionButton.small(
                heroTag: 'flash',
                onPressed: _toggleFlash,
                backgroundColor: _flashOn ? Colors.amber : Colors.black54,
                child: Icon(_flashOn ? Icons.flash_on : Icons.flash_off, color: Colors.white),
              ),
              // Capture — disabled when no objects detected
              FloatingActionButton(
                heroTag: 'capture',
                onPressed: (_capturing || _detections.isEmpty) ? null : _capture,
                backgroundColor: _detections.isEmpty ? Colors.grey : Colors.white,
                child: _capturing
                    ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
                    : Icon(Icons.camera, color: _detections.isEmpty ? Colors.white54 : Colors.black, size: 32),
              ),
              // Stop camera
              FloatingActionButton.small(
                heroTag: 'stop',
                onPressed: _stop,
                backgroundColor: Colors.red.shade700,
                child: const Icon(Icons.stop, color: Colors.white),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

// =============================================================================
// Gallery Tab
// =============================================================================
class _GalleryTab extends StatefulWidget {
  final PaddleDetection detector;
  final bool modelLoaded;
  const _GalleryTab({required this.detector, required this.modelLoaded});
  @override
  State<_GalleryTab> createState() => _GalleryTabState();
}

class _GalleryTabState extends State<_GalleryTab> {
  final _picker = ImagePicker();
  String? _imagePath;
  Size? _imageSize;
  List<DetectionResult> _detections = [];
  int _ms = 0;
  bool _busy = false;

  Future<void> _pick() async {
    if (!widget.modelLoaded) {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Model not loaded')));
      return;
    }
    final f = await _picker.pickImage(source: ImageSource.gallery);
    if (f == null) return;
    setState(() {
      _imagePath = f.path;
      _detections = [];
      _busy = true;
    });
    final sz = await _imgSize(f.path);
    final sw = Stopwatch()..start();
    final r = await widget.detector.detect(f.path);
    sw.stop();
    setState(() {
      _imageSize = sz;
      _detections = r;
      _ms = sw.elapsedMilliseconds;
      _busy = false;
    });
  }

  Future<Size> _imgSize(String p) async {
    final d = await File(p).readAsBytes();
    final c = await ui.instantiateImageCodec(d);
    final fr = await c.getNextFrame();
    final s = Size(fr.image.width.toDouble(), fr.image.height.toDouble());
    fr.image.dispose();
    return s;
  }

  @override
  Widget build(BuildContext context) => Column(
    children: [
      Expanded(
        child: _imagePath == null
            ? Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(Icons.photo_library_outlined, size: 64, color: Colors.grey[400]),
                    const SizedBox(height: 12),
                    const Text('Pick an image', style: TextStyle(color: Colors.grey)),
                  ],
                ),
              )
            : Stack(
                fit: StackFit.expand,
                children: [
                  Image.file(File(_imagePath!), fit: BoxFit.contain),
                  if (_detections.isNotEmpty && _imageSize != null)
                    LayoutBuilder(
                      builder: (_, c) => CustomPaint(
                        painter: _StaticPainter(
                          imageSize: _imageSize!,
                          detections: _detections,
                          displaySize: Size(c.maxWidth, c.maxHeight),
                        ),
                      ),
                    ),
                  if (_busy)
                    Container(
                      color: Colors.black26,
                      child: const Center(child: CircularProgressIndicator()),
                    ),
                ],
              ),
      ),
      if (_detections.isNotEmpty)
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
          color: Theme.of(context).colorScheme.surfaceContainerHighest,
          child: Row(
            children: [
              Text('${_detections.length} obj', style: const TextStyle(fontWeight: FontWeight.bold)),
              const Spacer(),
              Text('${_ms}ms', style: const TextStyle(fontFamily: 'monospace')),
            ],
          ),
        ),
      if (_detections.isNotEmpty)
        SizedBox(
          height: 90,
          child: ListView.builder(
            padding: const EdgeInsets.symmetric(horizontal: 12),
            itemCount: _detections.length,
            itemBuilder: (_, i) {
              final d = _detections[i];
              return ListTile(
                dense: true,
                leading: CircleAvatar(
                  radius: 14,
                  backgroundColor: _Cls.color(d.label),
                  child: Text(
                    _Cls.name(d.label),
                    style: const TextStyle(fontSize: 9, color: Colors.white, fontWeight: FontWeight.bold),
                  ),
                ),
                title: Text('${_Cls.name(d.label)} ${(d.probability * 100).toStringAsFixed(1)}%'),
                subtitle: Text(
                  '${d.x.toStringAsFixed(0)},${d.y.toStringAsFixed(0)} ${d.width.toStringAsFixed(0)}×${d.height.toStringAsFixed(0)}',
                ),
              );
            },
          ),
        ),
      Padding(
        padding: const EdgeInsets.all(12),
        child: SizedBox(
          width: double.infinity,
          child: FilledButton.icon(
            onPressed: _busy ? null : _pick,
            icon: const Icon(Icons.image_search),
            label: const Text('Pick & Detect'),
          ),
        ),
      ),
    ],
  );
}

// =============================================================================
// Settings
// =============================================================================
class _SettingsSheet extends StatefulWidget {
  final double threshold;
  final bool useCustom;
  final String? customParam, customBin;
  final bool modelLoaded, loading;
  final int numClass;
  final bool gpuAvailable;
  final bool useGpu;
  final bool antiSpoof;
  final ValueChanged<double> onThresholdChanged;
  final ValueChanged<bool> onGpuChanged;
  final ValueChanged<bool> onAntiSpoofChanged;
  final VoidCallback onPickModel, onPickBin, onPickLabels, onUseBundled, onReload;
  const _SettingsSheet({
    required this.threshold,
    required this.useCustom,
    required this.customParam,
    required this.customBin,
    required this.modelLoaded,
    required this.loading,
    required this.numClass,
    required this.gpuAvailable,
    required this.useGpu,
    required this.antiSpoof,
    required this.onThresholdChanged,
    required this.onGpuChanged,
    required this.onAntiSpoofChanged,
    required this.onPickModel,
    required this.onPickBin,
    required this.onPickLabels,
    required this.onUseBundled,
    required this.onReload,
  });
  @override
  State<_SettingsSheet> createState() => _SettingsSheetState();
}

class _SettingsSheetState extends State<_SettingsSheet> {
  late double _tr;
  late bool _gpu;
  late bool _spoof;
  @override
  void initState() {
    super.initState();
    _tr = widget.threshold;
    _gpu = widget.useGpu;
    _spoof = widget.antiSpoof;
  }

  @override
  Widget build(BuildContext context) => DraggableScrollableSheet(
    expand: false,
    initialChildSize: 0.45,
    minChildSize: 0.3,
    maxChildSize: 0.7,
    builder: (_, scroll) => ListView(
      controller: scroll,
      padding: const EdgeInsets.all(20),
      children: [
        Center(
          child: Container(
            width: 40,
            height: 4,
            decoration: BoxDecoration(color: Colors.grey[400], borderRadius: BorderRadius.circular(2)),
          ),
        ),
        const SizedBox(height: 16),
        Text('Settings', style: Theme.of(context).textTheme.titleLarge),
        const SizedBox(height: 16),
        Text('Threshold: ${_tr.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
        const Text('Only detections ≥ this score are shown', style: TextStyle(fontSize: 12, color: Colors.grey)),
        Slider(
          value: _tr,
          min: 0.1,
          max: 1.0,
          divisions: 90,
          onChanged: (v) {
            setState(() => _tr = v);
            widget.onThresholdChanged(v);
          },
        ),
        const Divider(),
        // GPU toggle
        Row(
          children: [
            const Text('Compute: ', style: TextStyle(fontWeight: FontWeight.bold)),
            const Spacer(),
            Text(
              widget.gpuAvailable ? (_gpu ? 'GPU (Vulkan)' : 'CPU') : 'CPU only (no GPU)',
              style: const TextStyle(fontSize: 12),
            ),
            const SizedBox(width: 8),
            Switch(
              value: _gpu,
              onChanged: widget.gpuAvailable
                  ? (v) {
                      setState(() => _gpu = v);
                      widget.onGpuChanged(v);
                    }
                  : null,
            ),
          ],
        ),
        if (!widget.gpuAvailable)
          const Text('GPU not available on this device', style: TextStyle(fontSize: 11, color: Colors.grey)),
        const Divider(),
        // Anti-spoof toggle
        Row(
          children: [
            const Text('Anti-Spoof: ', style: TextStyle(fontWeight: FontWeight.bold)),
            const Spacer(),
            Text(_spoof ? 'ON' : 'OFF', style: const TextStyle(fontSize: 12)),
            const SizedBox(width: 8),
            Switch(
              value: _spoof,
              onChanged: (v) {
                setState(() => _spoof = v);
                widget.onAntiSpoofChanged(v);
              },
            ),
          ],
        ),
        const Text(
          'Skip detection if image is from screen/monitor',
          style: TextStyle(fontSize: 11, color: Colors.grey),
        ),
        const Divider(),
        Text('Classes from model: ${widget.numClass}', style: const TextStyle(fontWeight: FontWeight.bold)),
        const SizedBox(height: 8),
        Text(
          widget.useCustom
              ? 'param: ${widget.customParam?.split('/').last ?? "not set"}\nbin: ${widget.customBin?.split('/').last ?? "not set"}'
              : 'Bundled model',
          style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            Expanded(
              child: OutlinedButton.icon(
                onPressed: widget.loading ? null : widget.onPickModel,
                icon: const Icon(Icons.description, size: 16),
                label: const Text('.param'),
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: OutlinedButton.icon(
                onPressed: widget.loading ? null : widget.onPickBin,
                icon: const Icon(Icons.data_object, size: 16),
                label: const Text('.bin'),
              ),
            ),
          ],
        ),
        const SizedBox(height: 8),
        OutlinedButton.icon(
          onPressed: widget.loading ? null : widget.onPickLabels,
          icon: const Icon(Icons.label_outline, size: 16),
          label: const Text('Pick labels.txt'),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            Expanded(
              child: FilledButton.icon(
                onPressed: widget.loading ? null : widget.onReload,
                icon: const Icon(Icons.refresh),
                label: const Text('Load Custom'),
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: OutlinedButton(
                onPressed: (widget.loading || !widget.useCustom) ? null : widget.onUseBundled,
                child: const Text('Use Bundled'),
              ),
            ),
          ],
        ),
      ],
    ),
  );
}

// =============================================================================
// Painters
// =============================================================================
class _StaticPainter extends CustomPainter {
  final Size imageSize;
  final List<DetectionResult> detections;
  final Size displaySize;
  _StaticPainter({required this.imageSize, required this.detections, required this.displaySize});
  @override
  void paint(Canvas canvas, Size size) {
    final sx = displaySize.width / imageSize.width;
    final sy = displaySize.height / imageSize.height;
    final s = sx < sy ? sx : sy;
    final ox = (displaySize.width - imageSize.width * s) / 2;
    final oy = (displaySize.height - imageSize.height * s) / 2;
    for (final d in detections) {
      final c = _Cls.color(d.label);
      final rect = Rect.fromLTWH(ox + d.x * s, oy + d.y * s, d.width * s, d.height * s);
      canvas.drawRect(
        rect,
        Paint()
          ..color = c
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2.5,
      );
      final label = '${_Cls.name(d.label)} ${(d.probability * 100).toStringAsFixed(0)}%';
      final tp = TextPainter(
        text: TextSpan(
          text: label,
          style: const TextStyle(color: Colors.white, fontSize: 12),
        ),
        textDirection: TextDirection.ltr,
      )..layout();
      final bg = Rect.fromLTWH(rect.left, rect.top - tp.height - 4, tp.width + 8, tp.height + 4);
      canvas.drawRect(bg, Paint()..color = c);
      tp.paint(canvas, Offset(bg.left + 4, bg.top + 2));
    }
  }

  @override
  bool shouldRepaint(covariant _StaticPainter old) => old.detections != detections || old.imageSize != imageSize;
}

// =============================================================================
class _Cls {
  static const _n = [
    'person',
    'bicycle',
    'car',
    'motorcycle',
    'airplane',
    'bus',
    'train',
    'truck',
    'boat',
    'traffic light',
    'fire hydrant',
    'stop sign',
    'parking meter',
    'bench',
    'bird',
    'cat',
    'dog',
    'horse',
    'sheep',
    'cow',
    'elephant',
    'bear',
    'zebra',
    'giraffe',
    'backpack',
    'umbrella',
    'handbag',
    'tie',
    'suitcase',
    'frisbee',
    'skis',
    'snowboard',
    'sports ball',
    'kite',
    'baseball bat',
    'baseball glove',
    'skateboard',
    'surfboard',
    'tennis racket',
    'bottle',
    'wine glass',
    'cup',
    'fork',
    'knife',
    'spoon',
    'bowl',
    'banana',
    'apple',
    'sandwich',
    'orange',
    'broccoli',
    'carrot',
    'hot dog',
    'pizza',
    'donut',
    'cake',
    'chair',
    'couch',
    'potted plant',
    'bed',
    'dining table',
    'toilet',
    'tv',
    'laptop',
    'mouse',
    'remote',
    'keyboard',
    'cell phone',
    'microwave',
    'oven',
    'toaster',
    'sink',
    'refrigerator',
    'book',
    'clock',
    'vase',
    'scissors',
    'teddy bear',
    'hair drier',
    'toothbrush',
    'LCK',
    'SCR',
  ];
  static const _c = [
    Colors.red, Colors.pink, Colors.orange, Colors.amber, Colors.yellow, Colors.lime,
    Colors.green, Colors.teal, Colors.cyan, Colors.blue, Colors.indigo, Colors.purple,
    Colors.red, Colors.pink, Colors.orange, Colors.amber, Colors.yellow, Colors.lime,
    Colors.green, Colors.teal, Colors.cyan, Colors.blue, Colors.indigo, Colors.purple,
    Colors.red, Colors.pink, Colors.orange, Colors.amber, Colors.yellow, Colors.lime,
    Colors.green, Colors.teal, Colors.cyan, Colors.blue, Colors.indigo, Colors.purple,
    Colors.red, Colors.pink, Colors.orange, Colors.amber, Colors.yellow, Colors.lime,
    Colors.green, Colors.teal, Colors.cyan, Colors.blue, Colors.indigo, Colors.purple,
    Colors.red, Colors.pink, Colors.orange, Colors.amber, Colors.yellow, Colors.lime,
    Colors.green, Colors.teal, Colors.cyan, Colors.blue, Colors.indigo, Colors.purple,
    Colors.red, Colors.pink, Colors.orange, Colors.amber, Colors.yellow, Colors.lime,
    Colors.green, Colors.teal, Colors.cyan, Colors.blue, Colors.indigo, Colors.purple,
    Colors.red, Colors.pink, Colors.orange, Colors.amber, Colors.yellow, Colors.lime,
    Colors.green, Colors.teal,
    Colors.green, Colors.purple, // LCK, SCR
  ];
  static String name(int l) => l >= 0 && l < _n.length ? _n[l] : 'C$l';
  static Color color(int l) => l >= 0 && l < _c.length ? _c[l] : Colors.grey;
}
1
likes
145
points
206
downloads

Documentation

API reference

Publisher

verified publishersaifulkamil.com

Weekly Downloads

High-performance offline object detection plugin using PP-PicoDet + NCNN with native camera, anti-spoof, and GPU acceleration.

Homepage

License

CC0-1.0 (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on fast_paddle_detection

Packages that implement fast_paddle_detection