DEV Community

Bastien Sulyan
Bastien Sulyan

Posted on Originally published at bsulyan.github.io

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 like jsQR: passing a full-resolution desktop screenshot (e.g. 2560×1440 on a HiDPI display) containing a small 140×140 QR code in one corner frequently returns null.

1. Why Single-Pass Full-Frame Scans Fail

Pure JavaScript QR decoders locate finder patterns (the nested 1:1:3:1:1 square ratios) after converting the RGBA buffer into a binary bit matrix. When the QR code occupies less than 2% of the frame area:

  • Downscaling the full viewport blurs high-version QR modules beyond the Nyquist limit.
  • Keeping native resolution increases the adaptive threshold block size relative to the modules, merging adjacent dark dots.
  • Translucent overlays reduce luminance contrast below detection margins.

2. Multi-Scale Overlapping Grid Pipeline

To achieve fast recognition on normal images while recovering small codes on dense screenshots, we run a cascaded pipeline:

  1. Pass 1 (Normalized Full Frame): Scan full image capped at 1024 px. Succeeds in ~8 ms for typical images.
  2. Pass 2 (Native Resolution): If source exceeds 1024 px, scan at native 1:1 resolution.
  3. Pass 3 (Overlapping 3x3 Spatial Grid): Divide image into 9 overlapping regions (each covering 48% width/height, stepped at 0%, 26%, 52%). The 22% overlap ensures any code straddling a boundary remains wholly inside at least one tile.
  4. Pass 4 (Luminance Binarization): Apply a hard luminance threshold at $Y = 128$ ($Y = 0.299R + 0.587G + 0.114B$) to rescue washed-out 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
  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;
    }
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

3. Client-Side RFC 6238 TOTP Generation (Web Crypto API)

When decoding otpauth://totp/... setup QR codes, computing 2FA codes locally prevents transmitting authentication secrets across the network. Using the W3C Web Cryptography API (crypto.subtle):

export async function generateTotp(secretBase32, timestampMs = Date.now(), period = 30, digits = 6) {
  const keyBytes = decodeBase32(secretBase32);
  const counter = Math.floor(timestampMs / 1000 / period);
  const counterBuffer = new ArrayBuffer(8);
  const counterView = new DataView(counterBuffer);
  counterView.setBigUint64(0, BigInt(counter));

  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    keyBytes,
    { name: 'HMAC', hash: 'SHA-1' },
    false,
    ['sign']
  );

  const signature = await crypto.subtle.sign('HMAC', cryptoKey, counterBuffer);
  const sigBytes = new Uint8Array(signature);
  const offset = sigBytes[sigBytes.length - 1] & 0x0f;
  const binary =
    ((sigBytes[offset] & 0x7f) << 24) |
    ((sigBytes[offset + 1] & 0xff) << 16) |
    ((sigBytes[offset + 2] & 0xff) << 8) |
    (sigBytes[offset + 3] & 0xff);

  const otp = binary % (10 ** digits);
  return String(otp).padStart(digits, '0');
}
Enter fullscreen mode Exit fullscreen mode

Interactive demonstration and tools available on Sunasty.

Top comments (0)