Python Image Quality Gate
Microservice that rejects blurry or noisy uploads to improve dataset quality.
Tech: Python, FastAPI, OpenCV, NumPy, Docker
Try it: score an image
I built Image Quality Gate because real-world upload flows collect a surprising number of unusable photos: motion-blurred night shots, overexposed sun glare, upside-down images with odd EXIF flags, and sometimes even non-images. Downstream, those bad inputs waste human review time, degrade model training data, and erode trust when they surface in dashboards. Rather than bolting on a heavyweight ML classifier, I wanted a deterministic, CPU-friendly guardrail that could sit directly beside an existing upload API and make fast, transparent yes/no decisions. The core idea: compute two inexpensive metrics—sharpness (variance of Laplacian) and brightness (mean grayscale)—then compare them to tuned thresholds. The result is a tiny service you can run anywhere that stops junk at the door and explains exactly why it said no.
Functionally, the service exposes a single decisive path: you POST a multipart image to /quality, and it responds with blur_score, brightness, width, height, a boolean is_ok, and the thresholds it applied. If blur_score is above BLUR_MIN and brightness falls between BRIGHT_MIN and BRIGHT_MAX, the image passes. Those thresholds live in environment variables, so teams can tune behavior per deployment without code changes or redeploys. Around the edge, the service includes health and version endpoints for rollouts, and a Prometheus metrics endpoint that counts requests, errors, and latency histograms to make operational insights straightforward.
Under the hood, the pipeline is deliberately simple and robust. Bytes arrive as multipart/form-data, are validated for size and type, corrected for EXIF orientation (a common reason for “rotated” photos), optionally resized so the longest side is bounded by RESIZE_MAX_DIM to control CPU, then converted to grayscale. Sharpness is computed with the Laplacian operator and summarized as the variance of response values—higher means more high-frequency detail and thus a sharper photo. Brightness is the per-pixel mean in the 0–255 range. These are stable, interpretable signals that behave well across cameras and don’t depend on GPUs or model weights. Their determinism is a feature: if an image fails, you can show the exact numbers and thresholds to a user or support engineer.
This design fits neatly into a standard presigned-upload architecture. Your primary backend (for example, a public-facing Spring Boot service) issues an S3 presigned URL. The client uploads original bytes to S3. Separately, your backend fetches either those bytes or a downscaled preview and POSTs to /quality. If is_ok is false, you can either reject the record (and ask the client to retake the photo) or quarantine the object by tagging it in object storage. If it passes, you persist the metadata—including blur_score for later analytics—next to your main domain model. The key property is idempotence: running the same image through the service will always produce the same result at a given configuration, which makes retries safe.
# Quickstart (local)
uvicorn app.main:app --host 0.0.0.0 --port 8080
# Basic health check
curl -s http://localhost:8080/health
# Score an image (multipart upload)
curl -s -F "file=@samples/sharp/street_001.jpg" http://localhost:8080/quality | jq
A typical response includes the raw metrics, image dimensions, the decision, and the thresholds used. Returning thresholds in every response makes investigations simpler when you’re tuning knobs in multiple environments (local, staging, production). It also keeps behavior auditable over time, which matters when someone asks, “Why did this fail yesterday but pass today?”
{
"blur_score": 513.42,
"brightness": 121.8,
"width": 1280,
"height": 960,
"is_ok": true,
"thresholds": { "blur_min": 160.0, "bright_min": 40, "bright_max": 180 }
}
Configuration is done entirely through environment variables. BLUR_MIN controls the minimum acceptable sharpness. BRIGHT_MIN and BRIGHT_MAX clamp the acceptable brightness window. RESIZE_MAX_DIM provides a performance/quality tradeoff: decreasing it speeds up processing at the cost of slightly different scores (re-tune after large changes). MAX_UPLOAD_MB prevents abuse and accidental huge payloads; LOG_LEVEL and LOG_JSON make logs structured in production; PROMETHEUS_ENABLED toggles metrics export. Because these settings are declarative, DevOps teams can vary tolerances per tenant or per region without changing code.
# .env example
BLUR_MIN=160
BRIGHT_MIN=40
BRIGHT_MAX=180
RESIZE_MAX_DIM=1600
MAX_UPLOAD_MB=6
LOG_LEVEL=INFO
LOG_JSON=true
PROMETHEUS_ENABLED=true
APP_NAME=image-quality-gate
APP_VERSION=0.1.0
For threshold tuning, the repo ships with a helper that scans a folder of images, computes blur and brightness, and suggests a BLUR_MIN cut using a log-space Otsu threshold. It also supports a labeled mode: drop examples into folders named sharp and blur_* (e.g., blur_motion, blur_defocus) and you’ll get per-class histograms plus a CSV. The message is to calibrate with your data sources—nighttime phone shots differ from daytime DSLR images. The recommended starting point from an even mix of sharp vs. blurry images was BLUR_MIN ≈ 160 with brightness bounds [40, 180], but different cameras and lighting will move these slightly. After changing RESIZE_MAX_DIM or camera populations, rerun the tuner and update the .env.
# Example tuning workflow
python scripts/tune_blur.py samples --labels --save-csv out/results.csv --save-hist out/hists
# After reviewing suggested thresholds, update .env and restart the service
Performance-wise, this service is intentionally CPU-only and lightweight. On 1080p inputs with RESIZE_MAX_DIM set to 1600, median latency typically sits around 30–60 ms, with p95 around 80–120 ms on a commodity laptop. Local throughput can exceed 100 requests/sec using two Uvicorn workers. If you tighten MAX_UPLOAD_MB and use downscaled previews, you can push these numbers further. Any change to RESIZE_MAX_DIM affects both latency and the absolute scale of blur_score, so always revisit tuning when you adjust image sizes.
Security and robustness were non-negotiables. The API accepts only multipart uploads—no remote URL fetching—which eliminates a common SSRF class. Type and size checks reject non-images (415) and oversized payloads (413) early. EXIF orientation is corrected before analysis so rotated photos don’t poison scores. In production, CORS should be restricted to your domains, and logs remain structured without ever including raw image bytes. The code path is idempotent and safe to retry; repeated requests with the same bytes will compute the same metrics and decision under the same configuration.
Deployment is straightforward. The Dockerfile produces a single, portable image. Run it directly with docker run -p 8080:8080 --env-file .env, or wire it into Docker Compose alongside your backend. For heterogeneous environments (e.g., Apple Silicon developers shipping to x86_64 servers), you can build with an explicit platform to avoid surprises. CI executes unit and integration tests on every push, and the Prometheus metrics endpoint makes it trivial to alert on elevated error rates or latency. The goal throughout was “production-ready small”: narrow surface area, clear failure modes, and enough observability to be boring in real life. That is ultimately the point of Image Quality Gate: a dependable, fast, and understandable decision that keeps the rest of your pipeline clean.