image_quality_gate 0.1.0
image_quality_gate: ^0.1.0 copied to clipboard
Reject blurry, dark, overexposed, glary, low-contrast or undersized photos before OCR, upload or document scanning, with typed reasons and messages.
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.