document_scan 0.2.0 copy "document_scan: ^0.2.0" to clipboard
document_scan: ^0.2.0 copied to clipboard

Composable, native-light document scanner for Flutter: document corner detection plus perspective correction, for both live camera frames and still images.

πŸ“„ document_scan #

pub package pub points likes platform license: MIT

Flutter Dart OpenCV Apple Vision

A composable, native-light document scanner for Flutter. Find a document's four corners in a photo or a live camera frame, then perspective-correct and filter it into a clean scan.

  • 🧩 Composable, not a black box. Two independent pieces β€” a DocumentDetector that finds corners and a DocumentProcessor that warps and filters. Use them together, or take just the part you need.
  • πŸŽ›οΈ Widget-free. The package returns data (corners, image bytes). You build the camera UI, the overlay, and the "capture" button exactly how you want.
  • πŸͺΆ Native-light. Corner detection uses the platform's own vision engine β€” Apple Vision on iOS (0 MB) and OpenCV on Android β€” with no bundled ML model, no OCR, and no camera dependency.
  • πŸ“ Detects documents, not text. A page is found by its rectangle, so blank pages, drawings, and forms work too.

Own the camera UX. Keep the scan engine. Not a fullscreen scanner plugin β€” a document scan engine you drive from your own UI.

[Realtime document detection β€” the green quad tracks the document live, then auto-captures]

πŸ€” Why this exists #

Most Flutter document scanners are either locked to a native fullscreen flow you can't restyle, or a rigid black box that doesn't fit a custom product UX. document_scan gives you the scan pipeline as data-first APIs β€” corners and pixels β€” so the camera UI, the overlay, the capture button, and the review screen are all yours to build.

It is a detection + perspective-correction + filtering pipeline. It is not a fullscreen scanner UI, an OCR engine, or a camera package β€” bring your own camera and feed it frames or files.

How it compares #

Most Flutter document scanners fall into two camps. Neither lets you own the UI while staying offline:

Fullscreen plugins ML Kit / VisionKit wrappers document_scan
Build your own UI (headless) ❌ ❌ βœ…
Works fully offline (no Play Services) ⚠️ varies ❌ downloads at runtime βœ…
Realtime live-frame detection some ❌ still capture only βœ…
Composable detector + processor ❌ ❌ βœ…
Returns data (corners + bytes), not a screen ❌ file paths ❌ file paths βœ…
Manual corner adjustment ⚠️ ❌ βœ…

The ML-Kit-based wrappers download their models and scanning UI through Google Play Services at first run β€” great for a drop-in screen, but not offline, not customizable, and (for the document scanner) Android-only. The fullscreen plugins render their own UI and hand back cropped file paths, not geometry. document_scan gives you the geometry and pixels and gets out of your way.

πŸ’‘ Best for #

  • Custom banking / KYC capture flows
  • Receipt and invoice scanning
  • Form and document capture
  • Apps with their own embedded camera UX
  • Gallery-import + manual-correction workflows

πŸ”§ How it works #

The two pieces are independent β€” DocumentScanner just ties them together:

  ScanInput          DocumentDetector          DocumentProcessor
  (file / bytes  ──▢  native vision     ──▢     warp + filter    ──▢  ScannedDocument
   / camera frame)    β†’ corners (0..1)                                 (png/jpeg/pdf)
                              β”‚                        β–²
              (A) automatic β”€β”€β”˜   (B) user drags cornersβ”˜

  Live camera:  frames ──▢ detectStream ──▢ DetectionEvent
                                β”‚
                                β”œβ”€β”€β–Ά CornerStabilizer   (steady overlay)
                                └──▢ AutoCaptureAnalyzer (auto-shoot)

Two ways to crop a still image:

  • (A) Automatic β€” scanner.scan(input) detects the corners and returns the finished scan in one call. The user never touches the corners.
  • (B) User-corrected β€” show the detected corners, let the user drag them, then scanner.scan(input, corners: edited) crops with exactly those.

πŸ“¦ Install #

dependencies:
  document_scan: ^0.2.0

πŸ–ΌοΈ Scan a still image #

[Capturing a document from the camera and scanning it to a clean crop]

The one-call path β€” DocumentScanner detects the corners and returns a clean, upright scan:

import 'package:document_scan/document_scan.dart';

final scanner = DocumentScanner();

final scan = await scanner.scan(ScanInput.file('/path/to/photo.jpg'));
// null when no document-like rectangle is found.
if (scan != null) {
  // scan.bytes is a PNG by default β€” show it with Image.memory, save it, …
  Image.memory(scan.bytes);
}

Pick a filter or a different output encoding:

final pdf = await scanner.scan(
  ScanInput.file(path),
  filter: ScanFilter.enhance,       // clean "scanned" look (the default)
  output: ScanOutputFormat.pdf,     // or .jpeg / .jpegAt(92); default is PNG
);
// pdf!.bytes is now a single-page A4 PDF.

Already have user-corrected corners (e.g. from a drag-to-adjust overlay)? Pass them and detection is skipped:

final scan = await scanner.scan(input, corners: editedCorners);

[Manual corner adjustment β€” dragging the quad handles to correct the crop]

Stays off the UI thread. The warp is pure-Dart CPU work (β‰ˆ1s on a full-frame photo), so scan runs it on a background isolate by default β€” you don't need compute/Isolate.run. Pass background: false if you're already calling from your own background isolate.

…or compose the pieces yourself #

DocumentScanner is a thin tie between two independent pieces you can use on their own β€” a DocumentDetector (finds corners) and a DocumentProcessor (warps + filters).

Which API should I use?

Goal Use
Quickest path β€” detect + crop in one call DocumentScanner
Just find the corners (draw them, confirm, adjust) DocumentDetector
Crop/filter with corners you already have DocumentProcessor
Watch a live camera stream for documents DocumentDetector.detectStream
Decide when a held document is steady enough to shoot AutoCaptureAnalyzer
final detector = DocumentDetector();
final processor = const DocumentProcessor();

final input = ScanInput.file('/path/to/photo.jpg');

// 1. Find the document's four corners (normalized 0..1, ordered TL/TR/BR/BL).
final corners = await detector.detect(input);

if (corners != null) {
  // 2. Perspective-correct + filter into an upright scan. Pass background: true
  //    to run the warp on an isolate (the primitive defaults to foreground, so
  //    it composes with your own threading; scan() opts in for you).
  final scan = await processor.crop(
    input,
    corners,
    filter: ScanFilter.enhance,
    background: true,
  );
  // scan!.bytes β€” save it, show it with Image.memory, …
}

πŸŽ₯ Scan from a live camera stream #

The package never opens a camera. Feed it frames from your own capture (e.g. the camera package) as ScanInputs. Each frame yields a DetectionEvent so you can tell why a frame had no corners β€” no document, a dropped frame under load, or a detection error β€” instead of a blind null:

final subscription = detector
    .detectStream(myCameraFrames) // Stream<ScanInput>
    .listen((event) {
      switch (event) {
        case DetectionSuccess(:final corners):
          setState(() => _corners = corners); // draw in your overlay
        case DetectionEmpty():
          setState(() => _corners = null);    // hint: "point at a document"
        case DetectionSkipped():
          break; // normal backpressure under load β€” ignore
        case DetectionError(:final error):
          debugPrint('detect failed: $error'); // stream stays alive
      }
    });

Steady overlay (corner stabilization) #

Raw corners jitter a pixel or two every frame even for a still document. Pass a CornerStabilizer to smooth them for the overlay β€” an exponential moving average that damps jitter but still tracks real movement, and snaps (rather than slides) when the document jumps:

detector.detectStream(myCameraFrames, stabilize: CornerStabilizer());
// DetectionSuccess.corners are now smoothed. Tune CornerStabilizer(smoothing:,
// resetDistance:) for steadier-but-laggier vs snappier.

Detection rate & sensitivity #

A camera pushes 30–60 frames a second, but running native detection on every one just heats the device without a smoother overlay. Cap the rate with minInterval β€” a frame that arrives too soon is dropped (as a DetectionSkipped) before it reaches the engine. sensitivity tunes how eagerly a rectangle counts as a document: detectStream defaults to strict (fewer false locks on tabletops and shadows while framing), scan() to lenient (the user already committed to a document), detect() to balanced.

detector.detectStream(
  myCameraFrames,
  minInterval: const Duration(milliseconds: 100), // ~10 detections/sec
  sensitivity: DetectionSensitivity.strict,       // strict | balanced | lenient
);

Detection runs at a capped resolution (~720px), so ResolutionPreset.high is plenty β€” the extra pixels don't improve corner-finding and only cost time. Frame drops under load are normal and surface as DetectionSkipped, not errors; on a slower device the effective rate just settles below your minInterval cap.

Auto-capture #

Wire AutoCaptureAnalyzer to fire once the document has been held steady and confident long enough β€” you take the still and crop it:

final analyzer = AutoCaptureAnalyzer();

// Pipe the detector's event stream straight in β€” bindEvents keeps the
// DetectionSkipped / DetectionError distinction (a dropped frame holds the
// countdown; a lost document resets it):
analyzer.bindEvents(detector.detectStream(frames)).listen((state) {
  if (state.status == AutoCaptureStatus.ready) capture();
});

// Or, if you already have a plain corner stream, use bindCorners(cornerStream)
// β€” or call analyzer.add(corners) yourself per frame.

Frames that arrive while a previous one is still being processed are dropped, so the stream never backs up.

Building a camera frame #

Use the format-specific factory so the required planes are actually required β€” yuvFrame on Android, bgraFrame on iOS:

// Android (YUV420): the three planes are required.
final input = ScanInput.yuvFrame(
  width: image.width,
  height: image.height,
  rotation: 90,
  yBytes: image.planes[0].bytes,
  uBytes: image.planes[1].bytes,
  vBytes: image.planes[2].bytes,
  yRowStride: image.planes[0].bytesPerRow,
  uvRowStride: image.planes[1].bytesPerRow,
  uvPixelStride: image.planes[1].bytesPerPixel ?? 1,
);

// iOS (BGRA): the single interleaved plane is required.
final input = ScanInput.bgraFrame(
  width: image.width,
  height: image.height,
  rotation: 0,
  bytes: image.planes[0].bytes,
  bytesPerRow: image.planes[0].bytesPerRow,
);

🎨 Filters #

DocumentProcessor applies pure-Dart filters after cropping:

ScanFilter Result
none The cropped color image, untouched.
grayscale Desaturated.
enhance Grayscale + contrast + normalize β€” the clean, readable "scanned" default.
blackWhite High-contrast "scanned paper" look.
sharpen Crisper text edges.
magicColor Brightened, saturated color for photos/receipts.

Detect and crop once, then swap filters cheaply β€” only the filter re-runs, not detection β€” via DocumentProcessor:

[Re-filtering a scanned document live]

πŸ’Ύ Output formats #

output: picks how the scan is encoded β€” the same cropped, filtered image, a different container:

ScanOutputFormat bytes are…
ScanOutputFormat.png PNG (the default).
ScanOutputFormat.jpeg JPEG at the default quality (90).
ScanOutputFormat.jpegAt(92) JPEG at a specific quality.
ScanOutputFormat.pdf A single-page A4 PDF of the scan.

Multi-page PDF #

Collect several scans in a ScanSession (an immutable, framework-free container β€” add / reorder / remove pages), then export them as one PDF:

var session = const ScanSession();
session = session.add(ScannedPage(document: scan1));
session = session.add(ScannedPage(document: scan2));

final pdf = await const DocumentProcessor()
    .pagesToPdf([for (final p in session.pages) p.document]);
// pdf!.bytes β€” a single multi-page PDF, one A4 page per scan.

[Collecting several scans into a multi-page session and exporting one PDF]

πŸ“€ What you get back #

  • DocumentCorners β€” four corners, always ordered top-left β†’ top-right β†’ bottom-right β†’ bottom-left, normalized to 0..1. Ordering is derived geometrically, so it's consistent regardless of platform. Carries a confidence (0..1 β€” the engine's own on iOS, a geometric heuristic on Android), plus area, isConvex, and toPixels(w, h) helpers.
  • ScannedDocument β€” the encoded bytes (PNG / JPEG / PDF per output) plus the image width/height.

βš–οΈ Platform differences #

Corner detection is native, and the two engines are not identical. None of this leaks into the API β€” you always get normalized corners β€” but it affects what gets detected, so it's documented honestly rather than hidden:

  • Detection engine. iOS uses Apple Vision (VNDetectRectanglesRequest); Android uses an OpenCV contour pipeline. The same photo can be found on one platform and missed on the other, especially near the edges of detectability.
  • Aspect / size gating. iOS applies Vision's own gates at detection (min aspect 0.1, max aspect 1.0, min size 5% of frame), so a very wide landscape document may be filtered out on iOS but not Android, which takes the largest convex quad and scores aspect only afterward.
  • confidence is not comparable across platforms. On iOS it's Vision's own probability; on Android there is no native probability, so it's a geometric heuristic (convexity + area + aspect) with a ~0.4 floor. Don't reuse a single minConfidence / AutoCaptureAnalyzer threshold across platforms expecting identical behaviour β€” tune per platform if you gate on it.
  • Realtime frame format. iOS streams accept BGRA only; Android accepts YUV420 or BGRA. Feed BGRA on iOS, YUV420 (or BGRA) on Android. detectFile (still images) has no such restriction.
  • Detection resolution. Both platforms detect at a capped resolution for speed, and β€” importantly β€” the still-image and live-frame paths use the same cap so a document detects consistently whether it comes from the gallery or the camera. (Android caps both at 720px; iOS Vision's gates are resolution-relative, so its paths agree without an explicit cap.)

πŸ“± App size #

Corner detection is native, but stays light:

Platform Engine Added size
iOS Apple Vision 0 MB (OS)
Android OpenCV native .so

On Android, ship only the ABIs your users need β€” a single-ABI (arm64-v8a) release keeps OpenCV's footprint to roughly one architecture's worth instead of all of them:

// android/app/build.gradle
android {
  splits { abi { enable true; reset(); include 'arm64-v8a'; universalApk false } }
}

πŸ” Permissions #

The package requests none. Its Android manifest declares no permissions, and its iOS privacy manifest lists no accessed APIs, no tracking, and no data collection β€” detection runs on the frames and files you hand it, on-device.

Because the package never opens a camera or the gallery, any permission your app needs comes from your capture layer, and you declare it β€” e.g. an NSCameraUsageDescription in Info.plist and a CAMERA permission for the camera package, or a photo-library permission for image_picker. If you only feed it file paths you already have, you may need no permissions at all.

🧭 Design #

The package deliberately owns as little as possible: no camera, no OCR, no UI. It gives you corners and pixels; everything above that is yours. If a native engine is unavailable, detection returns null rather than throwing β€” your app keeps running.

πŸ‘€ Author #

Built by Oğuzhan Γ–zdemir.

GitHub

Issues and PRs welcome at github.com/Ozdemiroguz/document_scan.

πŸ“„ License #

MIT

5
likes
0
points
491
downloads

Publisher

unverified uploader

Weekly Downloads

Composable, native-light document scanner for Flutter: document corner detection plus perspective correction, for both live camera frames and still images.

Repository (GitHub)
View/report issues

Topics

#document-scanner #computer-vision #camera #opencv #image-processing

License

unknown (license)

Dependencies

flutter, image, meta, pdf

More

Packages that depend on document_scan

Packages that implement document_scan