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

Lightweight classical-CV document edge detection and perspective-crop FFI plugin — no ML models, no Play Services.

example/lib/main.dart

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

import 'package:camera/camera.dart';
import 'package:doc_scan_lite/doc_scan_lite.dart';
import 'package:flutter/material.dart';

late List<CameraDescription> _cameras;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  _cameras = await availableCameras();
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'doc_scan_lite example',
      theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
      home: _cameras.isEmpty
          ? const Scaffold(body: Center(child: Text('No camera available')))
          : ScannerScreen(camera: _cameras.first),
    );
  }
}

class ScannerScreen extends StatefulWidget {
  const ScannerScreen({super.key, required this.camera});

  final CameraDescription camera;

  @override
  State<ScannerScreen> createState() => _ScannerScreenState();
}

class _ScannerScreenState extends State<ScannerScreen>
    with WidgetsBindingObserver {
  late CameraController _cameraController;
  late final Future<void> _initFuture;
  final DocScanController _scanController = DocScanController();

  ui.Image? _capturedPreview;
  bool _capturing = false;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _cameraController = CameraController(
      widget.camera,
      ResolutionPreset.high,
      enableAudio: false,
      imageFormatGroup: ImageFormatGroup.yuv420,
    );
    _initFuture = _init();
  }

  Future<void> _init() async {
    await _cameraController.initialize();
    await _scanController.start();
    await _startStream();
  }

  Future<void> _startStream() async {
    if (_cameraController.value.isStreamingImages) return;
    await _cameraController.startImageStream(_scanController.processFrame);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (!_cameraController.value.isInitialized) return;
    if (state == AppLifecycleState.inactive ||
        state == AppLifecycleState.paused) {
      // Release the camera while backgrounded; the OS may reclaim it anyway.
      _cameraController.dispose();
    } else if (state == AppLifecycleState.resumed) {
      _reinitializeCamera();
    }
  }

  Future<void> _reinitializeCamera() async {
    _cameraController = CameraController(
      widget.camera,
      ResolutionPreset.high,
      enableAudio: false,
      imageFormatGroup: ImageFormatGroup.yuv420,
    );
    await _cameraController.initialize();
    if (!mounted) return;
    await _startStream();
    if (mounted) setState(() {});
  }

  Future<void> _capture() async {
    if (_capturing) return;
    setState(() => _capturing = true);

    final captured = await _scanController.captureAndCropLatest();
    final preview = captured == null
        ? null
        : await _grayscaleToImage(captured.bytes, captured.width, captured.height);

    if (!mounted) return;
    setState(() {
      _capturing = false;
      _capturedPreview = preview;
    });

    if (captured == null && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('No document detected — align it in frame first.')),
      );
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _cameraController.dispose();
    _scanController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('doc_scan_lite')),
      body: FutureBuilder<void>(
        future: _initFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(child: Text('Camera init failed: ${snapshot.error}'));
          }
          return Stack(
            fit: StackFit.expand,
            children: [
              DocScannerPreview(
                cameraController: _cameraController,
                controller: _scanController,
              ),
              Positioned(
                bottom: 24,
                left: 0,
                right: 0,
                child: Center(
                  child: AnimatedBuilder(
                    animation: _scanController,
                    builder: (context, _) {
                      return FloatingActionButton.extended(
                        onPressed: _capturing ? null : _capture,
                        icon: _capturing
                            ? const SizedBox(
                                width: 16,
                                height: 16,
                                child: CircularProgressIndicator(strokeWidth: 2),
                              )
                            : const Icon(Icons.crop),
                        label: Text(
                          _scanController.isLocked ? 'Capture' : 'Align document',
                        ),
                      );
                    },
                  ),
                ),
              ),
              if (_capturedPreview != null) _buildCapturedOverlay(),
            ],
          );
        },
      ),
    );
  }

  Widget _buildCapturedOverlay() {
    return Positioned.fill(
      child: ColoredBox(
        color: Colors.black87,
        child: SafeArea(
          child: Column(
            children: [
              Expanded(
                child: InteractiveViewer(
                  child: Center(child: RawImage(image: _capturedPreview)),
                ),
              ),
              Padding(
                padding: const EdgeInsets.all(16),
                child: TextButton(
                  onPressed: () => setState(() => _capturedPreview = null),
                  child: const Text(
                    'Close',
                    style: TextStyle(color: Colors.white),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

/// Expands single-channel grayscale bytes into RGBA (dart:ui has no
/// single-channel decode path) and decodes them into a displayable
/// [ui.Image].
Future<ui.Image> _grayscaleToImage(Uint8List gray, int width, int height) {
  final rgba = Uint8List(width * height * 4);
  for (var i = 0; i < gray.length; i++) {
    final v = gray[i];
    final o = i * 4;
    rgba[o] = v;
    rgba[o + 1] = v;
    rgba[o + 2] = v;
    rgba[o + 3] = 255;
  }

  final completer = Completer<ui.Image>();
  ui.decodeImageFromPixels(
    rgba,
    width,
    height,
    ui.PixelFormat.rgba8888,
    completer.complete,
  );
  return completer.future;
}
3
likes
160
points
154
downloads

Documentation

API reference

Publisher

verified publisherchinmaysinghmodak.com

Weekly Downloads

Lightweight classical-CV document edge detection and perspective-crop FFI plugin — no ML models, no Play Services.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

camera, ffi, flutter, plugin_platform_interface

More

Packages that depend on doc_scan_lite

Packages that implement doc_scan_lite