Decoding Small and Low-Contrast QR Codes on HTML5 Canvas

By Bastien Sulyan · October 2026 · Computer Vision & Web APIs

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:

  1. Pass 1 (Normalized Full Frame): Scan the full image capped at 1024 px along its longest edge. If a QR fills the image, this succeeds in ~8 ms.
  2. Pass 2 (Native Resolution): If the source exceeds 1024 px, scan at native 1:1 pixel resolution.
  3. Pass 3 (Overlapping 3×3 Spatial Grid): Divide the image into 9 overlapping regions (each covering 48% of width and height, stepped at 0%, 26%, and 52%). The 22% overlap ensures that a QR code straddling a tile boundary remains wholly inside at least one tile. Each tile is upscaled 1.5× with nearest-neighbor / crisp interpolation before decoding.
  4. 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.