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).

pub package License: MIT

Features

  • ImageQualityGate(config).evaluate(bytes) returns a QualityReport with passed, a list of typed QualityIssues and all the raw QualityMetrics. 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 (error rejects, warning only 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, document and photo.
  • Region of interest: full frame, centre crop, relative rectangle or pixel rectangle.
  • evaluateInIsolate (from image_quality_gate_io.dart) runs the work on a background isolate with Isolate.run. The main library imports neither dart:io nor dart: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's maxWidth: 2400). A photo of a receipt or a page gains nothing from 48 MP.
  • Use evaluateInIsolate so the UI isolate never waits for the decode.
  • If a platform codec gives you decoded pixels faster, build an Image and call evaluateImage. 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: null if 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. minWidth and minHeight are literal, so the presets set both to the same value.
  • evaluate depends on package: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).