Decoding Small and Low-Contrast QR Codes on HTML5 Canvas
When building the Sunasty Web QR Scanner and its companion open-source Chrome extension sunasty-qr-scanner, we noticed a recurring failure mode with standard JavaScript QR decoders such as jsQR: passing a full-resolution desktop screenshot (for example 2560×1440 on a Retina/HiDPI display) containing a small 140×140 QR code in one corner frequently returns null.
This note explains why single-pass adaptive binarization misses small or low-contrast codes on large canvases, how a multi-scale overlapping tile pipeline resolves the issue, and how to handle otpauth:// 2FA setup codes locally with SubtleCrypto. All of these techniques are deployed in the Sunasty QR toolkit.
1. Why Single-Pass Full-Frame Scans Fail
Pure JavaScript QR decoders locate the three finder patterns (the nested 1:1:3:1:1 black-white-black-white-black square ratios) after converting the RGBA buffer into a binary bit matrix using local adaptive thresholding. When the input image is 3.6 megapixels and the QR code occupies less than 2% of the frame area:
- Downscaling the full viewport to speed up detection blurs high-version QR modules (Version 10+ with dense payload URLs) beyond the Nyquist limit.
- Keeping the full viewport resolution increases the adaptive threshold block size relative to the small QR modules, merging adjacent dark modules around the timing pattern.
- Translucent UI overlays or colored brand QR codes reduce luminance contrast below the default threshold margin.
2. Multi-Scale Overlapping Grid + Contrast Fallback
To achieve fast recognition on normal images while recovering small codes on dense desktop screenshots, we run a cascaded pipeline:
- Pass 1 (Normalized Full Frame): Scan the full image capped at
1024 pxalong its longest edge. If a QR fills the image, this succeeds in ~8 ms. - Pass 2 (Native Resolution): If the source exceeds
1024 px, scan at native1:1pixel resolution. - Pass 3 (Overlapping 3×3 Spatial Grid): Divide the image into 9 overlapping regions (each covering
48%of width and height, stepped at0%,26%, and52%). The 22% overlap ensures that a QR code straddling a tile boundary remains wholly inside at least one tile. Each tile is upscaled1.5×with nearest-neighbor / crisp interpolation before decoding. - Pass 4 (Luminance Binarization): Apply a hard luminance threshold at
Y = 128(Y = 0.299R + 0.587G + 0.114B) to rescue washed-out gray-on-beige QR graphics.
export function decodeQrMultiScale(sourceCanvas, jsQR) {
const ctx = sourceCanvas.getContext('2d', { willReadFrequently: true });
const { width, height } = sourceCanvas;
// Pass 1: Direct scan
const fullData = ctx.getImageData(0, 0, width, height);
const directHit = jsQR(fullData.data, width, height, {
inversionAttempts: 'attemptBoth'
});
if (directHit && directHit.data) return directHit.data;
// Pass 2: Overlapping 3x3 sub-regions for small QR codes on large viewports
const tileCanvas = document.createElement('canvas');
const tileCtx = tileCanvas.getContext('2d', { willReadFrequently: true });
const offsets = [0, 0.26, 0.52];
const span = 0.48;
for (const ox of offsets) {
for (const oy of offsets) {
const sx = Math.floor(width * ox);
const sy = Math.floor(height * oy);
const sw = Math.floor(width * span);
const sh = Math.floor(height * span);
tileCanvas.width = Math.min(1200, Math.round(sw * 1.5));
tileCanvas.height = Math.min(1200, Math.round(sh * 1.5));
tileCtx.imageSmoothingEnabled = false;
tileCtx.drawImage(sourceCanvas, sx, sy, sw, sh, 0, 0, tileCanvas.width, tileCanvas.height);
const tileImg = tileCtx.getImageData(0, 0, tileCanvas.width, tileCanvas.height);
const hit = jsQR(tileImg.data, tileCanvas.width, tileCanvas.height, {
inversionAttempts: 'attemptBoth'
});
if (hit && hit.data) return hit.data;
}
}
// Pass 3: High-contrast binary threshold fallback
const bin = new Uint8ClampedArray(fullData.data);
for (let i = 0; i < bin.length; i += 4) {
const luma = 0.299 * bin[i] + 0.587 * bin[i + 1] + 0.114 * bin[i + 2];
const v = luma < 128 ? 0 : 255;
bin[i] = bin[i + 1] = bin[i + 2] = v;
bin[i + 3] = 255;
}
const binHit = jsQR(bin, width, height, { inversionAttempts: 'attemptBoth' });
return binHit && binHit.data ? binHit.data : null;
}
3. Client-Side RFC 6238 TOTP Generation from otpauth://
Many QR codes scanned on desktop screens are two-factor authentication enrollment codes formatted as otpauth://totp/Issuer:account?secret=BASE32SECRET&issuer=Issuer. Rather than forcing the user to copy the raw URI and manually extract the Base32 query parameter, we can decode the Base32 secret and compute the live 6-digit RFC 6238 TOTP code directly via the browser's built-in crypto.subtle API:
function decodeBase32(base32Str) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const cleaned = base32Str.toUpperCase().replace(/[^A-Z2-7]/g, '');
let bits = 0;
let value = 0;
const output = [];
for (let i = 0; i < cleaned.length; i++) {
value = (value << 5) | alphabet.indexOf(cleaned[i]);
bits += 5;
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return new Uint8Array(output);
}
export async function generateTotpToken(base32Secret, period = 30, digits = 6) {
const keyBytes = decodeBase32(base32Secret);
const counter = Math.floor(Date.now() / 1000 / period);
const counterBuffer = new ArrayBuffer(8);
const view = new DataView(counterBuffer);
view.setUint32(0, Math.floor(counter / 0x100000000), false);
view.setUint32(4, counter >>> 0, false);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
);
const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, counterBuffer));
const offset = hmac[hmac.length - 1] & 0x0f;
const binary =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
return String(binary % Math.pow(10, digits)).padStart(digits, '0');
}
4. Generating Crisp Vector and Raster QR Codes
When generating QR codes in the browser—whether in the Sunasty QR Code Generator or the Chrome popup—rendering modules as fractional coordinates on a canvas causes subpixel anti-aliasing that blurs module edges. Always snap module coordinates to integer pixel boundaries or emit an SVG <path> with a quiet zone of at least 4 modules around the symbol perimeter.