When a web app sends a user photo to a vision model, the most common failure is not the model. It is the photo: too dark, too blurry, or far too large. Each bad upload still costs a request, and the user gets a confident answer built on an unreadable image.
This post shows a small client-side gate that catches those cases with the Canvas API before anything is uploaded. A good example is an AI palm reading app, where the input is a photo of an open hand and the creases are low-contrast, so image quality matters a lot. The same code works for receipts, documents, plants, or any "upload a photo, get a result" feature.
1. Downscale first
Phone photos are often 12 megapixels or more. The checks below do not need that, and neither does most vision preprocessing. Draw the image onto a small canvas once and reuse it.
async function loadScaled(file, maxSide = 1024) {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
return { canvas, ctx, w, h };
}
createImageBitmap respects EXIF orientation in current browsers, so a portrait phone photo is not drawn sideways.
2. Convert to grayscale once
Both checks work on luminance, so compute it a single time.
function toGray(ctx, w, h) {
const { data } = ctx.getImageData(0, 0, w, h);
const gray = new Float32Array(w * h);
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
// Rec. 601 luma
gray[j] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
}
return gray;
}
3. Brightness and contrast
Mean luminance catches photos taken in the dark; the standard deviation catches flat, washed-out images where nothing stands out.
function exposureStats(gray) {
let sum = 0;
for (const v of gray) sum += v;
const mean = sum / gray.length;
let sq = 0;
for (const v of gray) sq += (v - mean) ** 2;
return { mean, std: Math.sqrt(sq / gray.length) };
}
4. Blur: variance of the Laplacian
A sharp image has strong local edges; a blurred one does not. Apply a 3x3 Laplacian kernel and take the variance of the response. Low variance means few edges, which usually means blur.
function laplacianVariance(gray, w, h) {
let sum = 0, sq = 0, n = 0;
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x;
const lap = gray[i - w] + gray[i + w] + gray[i - 1] + gray[i + 1] - 4 * gray[i];
sum += lap;
sq += lap * lap;
n++;
}
}
const mean = sum / n;
return sq / n - mean * mean;
}
This is the same measure many OpenCV tutorials use (cv2.Laplacian(img, cv2.CV_64F).var()), written without a dependency.
5. Put it together
const LIMITS = { minMean: 60, maxMean: 220, minStd: 25, minSharpness: 40 };
async function checkPhoto(file) {
const { ctx, w, h } = await loadScaled(file, 512);
const gray = toGray(ctx, w, h);
const { mean, std } = exposureStats(gray);
const sharpness = laplacianVariance(gray, w, h);
if (mean < LIMITS.minMean) return { ok: false, reason: 'Too dark. Try again in brighter, even light.' };
if (mean > LIMITS.maxMean) return { ok: false, reason: 'Overexposed. Avoid direct flash or sunlight.' };
if (std < LIMITS.minStd) return { ok: false, reason: 'Low contrast. Fill the frame with the subject.' };
if (sharpness < LIMITS.minSharpness) return { ok: false, reason: 'Blurry. Hold the phone still and tap to focus.' };
return { ok: true, mean, std, sharpness };
}
document.querySelector('#photo').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const result = await checkPhoto(file);
if (!result.ok) {
showRetakeHint(result.reason);
return;
}
upload(file);
});
Choosing the thresholds
The numbers above are starting points, not universal constants. The Laplacian variance depends on resolution and on the subject: a palm has fewer hard edges than a printed page, so its "sharp" score is lower. A simple way to tune them:
- Collect 50 to 100 real uploads, including bad ones.
- Log
mean,std, andsharpnessfor each and label them good or bad by eye. - Set each threshold just below the worst image you would still accept.
Always analyse at the same size (here 512 px on the long side), or the blur score will not be comparable between images.
Notes
- Keep the server-side check too. The client gate saves money and gives fast feedback, but anyone can bypass it.
- Tell the user why the photo was rejected and what to change. "Too dark, try brighter light" gets a better retake than a generic error.
- Run the check in a Web Worker if you analyse at larger sizes; at 512 px it takes a few milliseconds on the main thread.
A cheap check before an expensive model is one of the simplest ways to make an image feature both cheaper to run and more accurate for users.