image_quality_gate
Reject bad photos before OCR, upload or document scanning, and tell the user
exactly why. Pure Dart, web-safe, one dependency (package:image).
Features
ImageQualityGate(config).evaluate(bytes)returns aQualityReportwithpassed, a list of typedQualityIssues and all the rawQualityMetrics. Each issue carries its code, the metric it came from, the measured value, the threshold, whether that threshold is a minimum or a maximum, and a severity (errorrejects,warningonly informs).evaluateImage(Image)for images you have already decoded.- Checks:
- Sharpness: variance of the Laplacian, with Tenengrad (Sobel gradient energy) as an optional second metric.
- Exposure: mean luminance too dark or too bright, plus the fractions of clipped highlights and shadows.
- Glare: the fraction of saturated near-white pixels, optionally only those in connected blobs so scattered specks are ignored.
- Low contrast: RMS contrast.
- Resolution: minimum width, height and megapixels, and an aspect-ratio range.
- Document fill (a heuristic): flags a document that is too small in the frame.
- Presets:
QualityConfig.receipt,idCard,documentandphoto. - Region of interest: full frame, centre crop, relative rectangle or pixel rectangle.
evaluateInIsolate(fromimage_quality_gate_io.dart) runs the work on a background isolate withIsolate.run. The main library imports neitherdart:ionordart:isolate, so it also works on the web.- Built-in messages in English, French and Arabic for every issue code. You can override any of them.
Install
dart pub add image_quality_gate
Usage
import 'dart:typed_data';
import 'package:image_quality_gate/image_quality_gate.dart';
void check(Uint8List bytes) {
const gate = ImageQualityGate(QualityConfig.receipt);
final report = gate.evaluate(bytes);
if (!report.passed) {
final messages = QualityMessages.forLocale('fr');
for (final issue in report.errors) {
print(messages.forIssue(issue));
// e.g. "Photo floue. Tenez le téléphone immobile et recommencez."
print('${issue.metric} = ${issue.value} (${issue.kind.name} ${issue.threshold})');
}
}
print(report.metrics.laplacianVariance);
}
On a background isolate (native platforms only):
import 'package:image_quality_gate/image_quality_gate_io.dart';
Future<bool> isUsable(Uint8List bytes) async =>
(await evaluateInIsolate(bytes, QualityConfig.document)).passed;
Custom thresholds, a region of interest, and your own wording:
final config = QualityConfig.idCard.copyWith(
roi: const RegionOfInterest.centre(widthFraction: 0.9, heightFraction: 0.7),
glare: const GlareCheck(maxFraction: 0.002),
disable: {QualityCheck.documentFill},
);
final messages = QualityMessages.forLocale('ar', overrides: {
QualityIssueCode.glare: 'أمِل البطاقة قليلًا لإزالة الانعكاس.',
});
A decode failure throws UndecodableImageException. evaluate applies EXIF
orientation first, so width, height and the region of interest match what
the user saw on screen.
How it measures
Luminance is Rec. 601 luma (0.299 R + 0.587 G + 0.114 B) on a 0..255 scale, whatever the bit depth of the source.
| Metric | Definition | Measured on |
|---|---|---|
laplacianVariance |
variance of the [0 1 0; 1 -4 1; 0 1 0] response |
sharpness sample |
tenengrad |
mean of Gx² + Gy² (3x3 Sobel) | sharpness sample |
meanLuminance |
mean luma | ROI, every pixel |
highlightClipFraction / shadowClipFraction |
share of luma ≥ 250 / ≤ 5 (configurable) | ROI, every pixel |
glareFraction |
share of pixels with R, G and B all ≥ 250 | ROI, every pixel |
glareBlobFraction |
glare in 8-connected blobs ≥ minBlobFraction |
working image |
rmsContrast |
standard deviation of luma / 255 (0 to 0.5) | ROI, every pixel |
edgeDensity, documentFill |
see below | working image |
Sharpness sample. Downscaling is a low-pass filter. A mild blur on a 12 MP
photo shrinks to a fraction of a pixel on a 500 px copy and the blur signal
disappears; one test in this package shows this. By default the Laplacian
is therefore measured on a 600 x 600 window at native resolution, taken from
the centre of the region of interest. To measure the whole region downscaled
to workingSize instead, set SharpnessCheck(window: null, workingSize: ...).
Both metrics depend on scale and content. A threshold tuned for one camera
resolution does not carry over to another.
Working image. A single pass reads every pixel in the region. It gathers
the exposure, contrast and glare statistics, and it builds an area-averaged
copy whose long side is QualityConfig.workingSize (512 by default). Glare
blobs and the document-fill heuristic run on that copy.
Document fill is a heuristic. Strong edges (Sobel magnitude ≥
edgeThreshold) are found on the working image. documentFill is the area
of the box holding the central 96% of edge pixels on each axis, divided by
the region's area. It works for a document with visible content or a visible
border on a plainer background. A busy background (wood grain, a patterned
cloth) inflates it, and a blank page deflates it. It is not document
detection, so its presets report it as a warning, not an error.
How the presets were chosen
No published standard gives thresholds for these metrics on phone photos. Each value below is either taken from a source or is an engineering choice. Treat all of them as starting points and tune them on your own photos.
| Value | Where it comes from |
|---|---|
Laplacian variance 100 (idCard, document, photo) |
The default used in Adrian Rosebrock, "Blur detection with OpenCV", PyImageSearch (2015). It uses the same kernel as OpenCV's cv2.Laplacian. |
Laplacian variance 60, mean luminance ≥ 40, ≥ 1 MP (receipt) |
Values used in production apps for till-receipt OCR, measured on a 600 px native centre window as here. |
document ≥ 2 MP and each side ≥ 1000 px |
Tesseract's documentation recommends at least 300 DPI (ImproveQuality). An A4 page at 150 DPI is 1240 x 1754 px (2.2 MP). The preset allows phone captures that are a little under that. |
idCard ≥ 0.6 MP, aspect ratio 1.25 to 2 |
An ID-1 card (ISO/IEC 7810) is 85.60 x 53.98 mm (ratio 1.586). At 300 DPI it is 1011 x 638 px, about 0.64 MP. The ratio range allows loose framing, 4:3 and 16:9. |
photo ≥ 0.3 MP, each side ≥ 480 px |
VGA (640 x 480). |
| Exposure 40/50 to 220-235, clip levels 250 and 5, glare level 250 | Engineering choice. A white page under good light has a mean around 200-230. Clipping is a warning because a clipped white margin does not stop OCR. |
| Glare 0.5% to 2%, blob minimum 0.02% to 0.05% | Engineering choice. idCard is the strictest because a reflection on a laminated card hides fields. |
| RMS contrast 0.04 to 0.08 | Engineering choice. Thin receipt text covers only a few percent of the page, so a legible receipt can score about 0.1. |
| Document fill 0.2 / 0.35 / 0.4 | Engineering choice. A receipt is long and narrow, so its preset is the most lenient. |
The synthetic tests check that the presets order images correctly (sharp before blurred, dark before bright). They do not check that the thresholds are right for real photos. No real-photo corpus was used.
Performance
Measured with benchmark/benchmark.dart on a generated 4000 x 3000 (12 MP)
JPEG (quality 90, 3.6 MB), compiled with dart compile exe, on an Intel Core
i7-6700HQ (2016 laptop, 4 cores). The machine was busy with other jobs during
the run (load average 13-16), so these numbers are upper bounds.
| Step | Median of 5 |
|---|---|
img.decodeImage (package:image JPEG decoder) |
~7.1 s |
evaluateImage (all checks, receipt preset) |
~0.64 s |
evaluate(bytes) (decode + checks) |
~7.2 s |
Decoding dominates. evaluateImage reads each pixel of the region once;
everything else runs on a 512 px copy or a 600 px window. In practice:
- Cap the capture size (for example,
image_picker'smaxWidth: 2400). A photo of a receipt or a page gains nothing from 48 MP. - Use
evaluateInIsolateso the UI isolate never waits for the decode. - If a platform codec gives you decoded pixels faster, build an
Imageand callevaluateImage. Keep in mind that decoding at reduced size lowers the sharpness metrics, so thresholds need re-tuning.
Limitations
- Thresholds are content- and scale-dependent. A page with little text has a lower Laplacian variance than a busy one at the same focus.
- Only the centre window is checked for sharpness by default, so a photo
that is sharp in the centre and blurred at the edges (tilted document,
shallow depth of field) passes. Use a region of interest or
window: nullif that matters. - Glare detection finds saturated near-white areas. It cannot tell a reflection from a genuinely white object that is overexposed.
- Document fill is a heuristic (see above), not document detection or perspective estimation.
- Aspect ratio is long side over short side, so it ignores orientation.
minWidthandminHeightare literal, so the presets set both to the same value. evaluatedepends onpackage:image's decoders, which are pure Dart and slow on large JPEGs.
License
MIT. See LICENSE.
Made by Abdeldjalil Chougui.
Libraries
- image_quality_gate
- Reject bad photos before OCR, upload or document scanning, and say why.
- image_quality_gate_io
- Background evaluation for native platforms (not web).