document_scan 0.1.0
document_scan: ^0.1.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 #
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
DocumentDetectorthat finds corners and aDocumentProcessorthat 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.
π§ 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.1.0
πΌοΈ Scan a still image #
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);
Stays off the UI thread. The warp is pure-Dart CPU work (β1s on a full-frame photo), so
scanruns it on a background isolate by default β you don't needcompute/Isolate.run. Passbackground: falseif 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):
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.
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. |
πΎ 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. |
π€ 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 aconfidence(0..1 β the engine's own on iOS, a geometric heuristic on Android), plusarea,isConvex, andtoPixels(w, h)helpers.ScannedDocumentβ the encodedbytes(PNG / JPEG / PDF peroutput) plus the imagewidth/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.
confidenceis 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 singleminConfidence/AutoCaptureAnalyzerthreshold 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 } }
}
π§ 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.
Issues and PRs welcome at github.com/Ozdemiroguz/document_scan.
π License #
MIT