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

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;
  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.ncnn.param', binName: 'model.ncnn.bin', cpuGpu: cpuGpu);
      if (info.success) await _detector.setThreshold(threshold: _threshold);
      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> _pickModel() async {
    final r = await FilePicker.platform.pickFiles(allowMultiple: true, type: FileType.any);
    if (r == null) return;
    String? p, b;
    for (final f in r.files) {
      if (f.path == null) continue;
      if (f.path!.toLowerCase().endsWith('.param')) p = f.path;
      if (f.path!.toLowerCase().endsWith('.bin')) b = f.path;
    }
    if (p == null || b == null) {
      _snack('Pick both .param and .bin');
      return;
    }
    setState(() {
      _customParamPath = p;
      _customBinPath = b;
      _modelLoaded = false;
      _status = 'Custom model set';
    });
    _loadModel();
  }

  void _useBundled() {
    setState(() {
      _customParamPath = null;
      _customBinPath = 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,
      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: _pickModel,
      onUseBundled: _useBundled,
      onReload: () {
        Navigator.pop(context);
        _loadModel();
      },
    ),
  );

  @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;
  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, onUseBundled, onReload;
  const _SettingsSheet({
    required this.threshold,
    required this.useCustom,
    required this.customParam,
    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.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 ? 'Custom: ${widget.customParam?.split('/').last}' : 'Bundled model',
          style: const TextStyle(fontSize: 12),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            Expanded(
              child: OutlinedButton(
                onPressed: widget.loading ? null : widget.onPickModel,
                child: const Text('Pick model'),
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: OutlinedButton(
                onPressed: (widget.loading || !widget.useCustom) ? null : widget.onUseBundled,
                child: const Text('Bundled'),
              ),
            ),
          ],
        ),
        const SizedBox(height: 16),
        FilledButton.icon(
          onPressed: widget.loading ? null : widget.onReload,
          icon: const Icon(Icons.refresh),
          label: const Text('Reload Model'),
        ),
      ],
    ),
  );
}

// =============================================================================
// 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 = ['LCK', 'SCR'];
  static const _c = [Colors.green, Colors.purple];
  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.red;
}
1
likes
0
points
206
downloads

Publisher

verified publishersaifulkamil.com

Weekly Downloads

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

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on fast_paddle_detection

Packages that implement fast_paddle_detection