§ NOTAS TÉCNICAS aGo · 2026-05-24

Generar QR con dithering controlado preservando módulos críticos

aGo QR permite mezclar una foto con un código QR sin romper la decodificación. La técnica clave es aplicar dithering Floyd-Steinberg solo sobre módulos no críticos del QR y validar el resultado antes del export. Esta nota cuenta cómo lo armamos.

El contexto

Los QR funcionales sirven, pero son visualmente aburridos. En marketing, merchandising y empaquetado, un QR negro-sobre-blanco compite con el resto del diseño y suele perder. La pregunta recurrente del cliente es: "¿se puede hacer que el QR muestre la foto del producto y siga funcionando?". La respuesta corta: sí, con cuidado.

El cuidado está en dos cosas. Primera, un QR tiene módulos que el decodificador necesita ver con alto contraste para localizar y orientar el código: los tres ojos de las esquinas (finder patterns), el módulo de alineación inferior derecho (alignment pattern), las líneas de timing y la zona de quiet zone. Si esos módulos se contaminan con la foto, el QR deja de escanear.

Segunda, los QR tienen niveles de corrección de error (ECL): L (7 %), M (15 %), Q (25 %) y H (30 %). En ECL H, el decodificador tolera que hasta el 30 % de los módulos estén dañados o ambiguos y aún así recupera el contenido. Esa redundancia es la que nos da espacio para mezclar imagen sin romper la decodificación.

El constraint duro del producto: el QR resultante debe escanear con las cámaras nativas de iOS y Android del último par de años. Si solo escanea con apps de QR specializadas, no sirve. La validación con un decoder real corriendo en el browser (jsqr) es parte no negociable del pipeline.

Las opciones que evaluamos

1. Floyd-Steinberg dithering libre sobre todo el QR

Aplicar dithering Floyd-Steinberg directamente sobre la matriz QR compuesta con la foto. Es lo que hacen la mayoría de scripts caseros de "QR-art" que circulan por internet.

Resultado en pruebas: visualmente muy bonito, decodificación del 50-60 % en cámaras nativas. Inaceptable para producto.

2. Reemplazar puntos QR por shapes "artísticos" tipo formas redondeadas o pixel art

Es el approach de servicios comerciales tipo QRcode Monkey. Sustituyen cada módulo por un dot redondo, una estrella, o un pixel de imagen. Funciona si todas las shapes mantienen el contraste por módulo.

Contra: el resultado no se mezcla orgánicamente con la imagen. Se ve un QR con relleno de imagen, no una imagen con QR dentro. Para el caso de aGo QR queríamos algo más cercano al segundo efecto.

3. Floyd-Steinberg controlado: dither solo en módulos no críticos, ECL H

Detectar las regiones críticas del QR (finder, alignment, timing, quiet zone), congelarlas en blanco/negro puro de alto contraste, y aplicar dithering solo en las regiones data. Usar ECL H para tolerar el ruido inherente al dithering.

Resultado en pruebas: decodificación del 95-98 % en cámaras nativas para QR con URL típica (40-60 caracteres) en versión 5-6, ECL H. Y el visual integra la foto al QR de forma orgánica.

La decisión y por qué

Adoptamos la opción 3 con un paso de validación obligatorio al final. Razones:

Primera: solo este approach produce decodificación confiable y visual atractivo a la vez. Las otras dos opciones eligen uno u otro. En un producto de marketing, si el QR no escanea, la pieza completa pierde sentido.

Segunda: ECL H da 30 % de tolerancia a error. Esto es perfecto para absorber el ruido del dithering en la región data, mientras preservamos al 100 % los módulos críticos. Es el caso de uso para el que ECL H fue pensado, aunque históricamente se usa solo cuando se embebe logo en el centro.

Tercera: la validación con jsqr corre en el navegador en menos de 20ms para un QR de versión 5. Esto significa que cada vez que el usuario ajusta la foto, podemos validar en vivo y mostrar un indicador "QR escanea correctamente / no escanea". Si no escanea, el usuario sabe inmediatamente que tiene que reducir la opacidad de la foto o cambiar el contraste.

Cuarta y operacional: jsqr pesa ~50 KB gzip, qrcode-generator pesa ~15 KB gzip. La librería completa de QR-art que armamos pesa menos de 80 KB. Comparable a un servicio externo que requeriría upload de la imagen.

Cómo lo implementamos

El pipeline tiene cinco pasos: generar la matriz QR, calcular máscara de módulos críticos, redimensionar la foto al tamaño del QR final, aplicar dithering selectivo, validar con jsqr. Si validación falla, sugerir ajustes.

// src/islands/qr/lib/qr-art.ts
// QR-art con dithering Floyd-Steinberg controlado.
// Modulos criticos quedan intactos; modulos data se mezclan con la foto.

import QRCode from 'qrcode-generator';
import jsQR from 'jsqr';

export interface QrArtOptions {
  data: string;                // contenido del QR
  ecl?: 'L' | 'M' | 'Q' | 'H'; // recomendado H para arte
  pixelSize?: number;          // pixeles por modulo, default 12
  blendAmount?: number;        // 0..1, cuanto se mezcla la foto, default 0.6
  foto: ImageData;             // foto raster ya en tamano final
}

export interface QrArtResult {
  imageData: ImageData;
  decoded: string | null;      // null si el QR resultante no escanea
  matrixSize: number;          // numero de modulos por lado (ej. 41 en V6)
  pixelSize: number;
}

export function generateQrArt(opts: QrArtOptions): QrArtResult {
  const ecl = opts.ecl ?? 'H';
  const pixelSize = opts.pixelSize ?? 12;
  const blend = clamp01(opts.blendAmount ?? 0.6);

  // 1. Generar matriz QR.
  const qr = QRCode(0, ecl);
  qr.addData(opts.data);
  qr.make();
  const N = qr.getModuleCount();
  const size = N * pixelSize;

  // 2. Mascara de modulos criticos (1 = critico, 0 = data libre).
  const critical = buildCriticalMask(N);

  // 3. Foto adaptada al tamano final.
  const photo = resizeImageData(opts.foto, size, size);

  // 4. Renderizar QR a ImageData base (negro/blanco puro).
  const qrImg = renderQrToImageData(qr, pixelSize);

  // 5. Componer: para cada modulo, si es critico copiamos QR puro;
  //    si es data, mezclamos QR con foto y aplicamos Floyd-Steinberg
  //    restringido al rango compatible con el modulo (oscuro o claro).
  const out = new ImageData(size, size);
  for (let my = 0; my < N; my++) {
    for (let mx = 0; mx < N; mx++) {
      const isCritical = critical[my * N + mx] === 1;
      const moduleIsBlack = qr.isDark(my, mx);
      if (isCritical) {
        paintModule(out, mx, my, pixelSize, moduleIsBlack ? 0 : 255, 1);
      } else {
        // Sample del centro del modulo en la foto.
        paintModuleBlended(out, qrImg, photo, mx, my, pixelSize, moduleIsBlack, blend);
      }
    }
  }

  // 6. Floyd-Steinberg sobre la imagen completa, limitada a no cruzar
  //    los modulos criticos (los respeta porque ya son binarios puros).
  floydSteinbergSelective(out, critical, N, pixelSize);

  // 7. Validar con jsqr.
  const decoded = jsQR(out.data, out.width, out.height, { inversionAttempts: 'dontInvert' });

  return {
    imageData: out,
    decoded: decoded?.data ?? null,
    matrixSize: N,
    pixelSize
  };
}

// Marca como criticos: 3 finder patterns 7x7 + separadores, alignment patterns,
// lineas de timing horizontal y vertical, y los modulos de version/format.
function buildCriticalMask(N: number): Uint8Array {
  const mask = new Uint8Array(N * N);
  const markRect = (x: number, y: number, w: number, h: number) => {
    for (let dy = 0; dy < h; dy++) {
      for (let dx = 0; dx < w; dx++) {
        const xx = x + dx, yy = y + dy;
        if (xx >= 0 && xx < N && yy >= 0 && yy < N) mask[yy * N + xx] = 1;
      }
    }
  };
  // Finder patterns + separadores (8x8 cada uno).
  markRect(0, 0, 8, 8);
  markRect(N - 8, 0, 8, 8);
  markRect(0, N - 8, 8, 8);
  // Timing patterns (filas 6 y columnas 6).
  for (let i = 0; i < N; i++) {
    mask[6 * N + i] = 1;
    mask[i * N + 6] = 1;
  }
  // Alignment pattern central (V2+): aproximacion conservadora.
  if (N >= 25) {
    markRect(N - 9, N - 9, 5, 5);
  }
  // Format info bands adyacentes a finders.
  markRect(0, 8, 9, 1);
  markRect(8, 0, 1, 9);
  return mask;
}

function paintModule(
  out: ImageData,
  mx: number,
  my: number,
  pxSize: number,
  value: number,
  alpha: number
) {
  const startX = mx * pxSize, startY = my * pxSize;
  for (let dy = 0; dy < pxSize; dy++) {
    for (let dx = 0; dx < pxSize; dx++) {
      const idx = ((startY + dy) * out.width + (startX + dx)) * 4;
      out.data[idx] = out.data[idx + 1] = out.data[idx + 2] = value;
      out.data[idx + 3] = Math.round(alpha * 255);
    }
  }
}

function paintModuleBlended(
  out: ImageData,
  qrImg: ImageData,
  photo: ImageData,
  mx: number,
  my: number,
  pxSize: number,
  moduleIsBlack: boolean,
  blend: number
) {
  const startX = mx * pxSize, startY = my * pxSize;
  for (let dy = 0; dy < pxSize; dy++) {
    for (let dx = 0; dx < pxSize; dx++) {
      const ox = startX + dx, oy = startY + dy;
      const i = (oy * out.width + ox) * 4;
      const photoLuma = luma(photo.data, i);
      // Bias hacia el color del modulo: si el modulo es negro,
      // la mezcla se inclina hacia oscuro; si es blanco, hacia claro.
      const bias = moduleIsBlack ? 0 : 255;
      const mixed = Math.round(photoLuma * blend + bias * (1 - blend));
      out.data[i] = out.data[i + 1] = out.data[i + 2] = mixed;
      out.data[i + 3] = 255;
    }
  }
}

function floydSteinbergSelective(
  out: ImageData,
  critical: Uint8Array,
  N: number,
  pxSize: number
) {
  const W = out.width, H = out.height;
  const buf = new Float32Array(W * H);
  for (let i = 0; i < W * H; i++) buf[i] = out.data[i * 4];

  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const mx = Math.floor(x / pxSize), my = Math.floor(y / pxSize);
      if (critical[my * N + mx] === 1) continue;
      const i = y * W + x;
      const old = buf[i];
      const newV = old < 128 ? 0 : 255;
      buf[i] = newV;
      const err = old - newV;
      // Distribucion Floyd-Steinberg: 7/16, 3/16, 5/16, 1/16
      if (x + 1 < W) buf[i + 1] += err * 7 / 16;
      if (y + 1 < H) {
        if (x > 0) buf[i + W - 1] += err * 3 / 16;
        buf[i + W] += err * 5 / 16;
        if (x + 1 < W) buf[i + W + 1] += err * 1 / 16;
      }
    }
  }

  for (let i = 0; i < W * H; i++) {
    const v = Math.max(0, Math.min(255, Math.round(buf[i])));
    out.data[i * 4] = out.data[i * 4 + 1] = out.data[i * 4 + 2] = v;
  }
}

function luma(d: Uint8ClampedArray, i: number): number {
  return 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
}

function clamp01(n: number): number { return Math.max(0, Math.min(1, n)); }

function resizeImageData(src: ImageData, w: number, h: number): ImageData {
  const canvas = new OffscreenCanvas(w, h);
  const ctx = canvas.getContext('2d')!;
  const tmp = new OffscreenCanvas(src.width, src.height);
  tmp.getContext('2d')!.putImageData(src, 0, 0);
  ctx.imageSmoothingQuality = 'high';
  ctx.drawImage(tmp, 0, 0, w, h);
  return ctx.getImageData(0, 0, w, h);
}

function renderQrToImageData(qr: ReturnType<typeof QRCode>, pxSize: number): ImageData {
  const N = qr.getModuleCount();
  const size = N * pxSize;
  const data = new Uint8ClampedArray(size * size * 4);
  for (let my = 0; my < N; my++) {
    for (let mx = 0; mx < N; mx++) {
      const v = qr.isDark(my, mx) ? 0 : 255;
      for (let dy = 0; dy < pxSize; dy++) {
        for (let dx = 0; dx < pxSize; dx++) {
          const idx = ((my * pxSize + dy) * size + (mx * pxSize + dx)) * 4;
          data[idx] = data[idx + 1] = data[idx + 2] = v;
          data[idx + 3] = 255;
        }
      }
    }
  }
  return new ImageData(data, size, size);
}

Tres piezas son load-bearing. La primera es buildCriticalMask: marca explícitamente todas las regiones que el decodificador necesita ver limpias. Si esa máscara está mal o se queda corta, la decodificación falla incluso con ECL H. Vale la pena agregar tests contra QR de distintas versiones para confirmar que la máscara cubre los alignment patterns correctos.

La segunda es el floydSteinbergSelective: la variante clásica de Floyd-Steinberg pero saltando módulos críticos. El error se propaga normalmente a vecinos no críticos. Si el vecino es crítico, la propagación de error se "pierde" en ese módulo (que se pinta puro). Esto introduce una pequeña discontinuidad visual en el borde entre zona crítica y zona dither, pero es preferible a romper decodificación.

La tercera es la validación con jsqr al final. No confiamos en que el approach "debería funcionar". Lo verificamos cada vez. Si jsqr no decodifica el QR producido, la UI marca el estado como "Inválido, ajusta los parámetros" y propone reducir blend o cambiar a ECL más alto (si no estaba ya en H).

Limitaciones que descubrimos

Fotos muy texturadas (paisajes con muchas hojas, multitudes, telas bordadas) degradan rápido la decodificación incluso con dithering controlado. La razón es que la varianza local es alta y el Floyd-Steinberg produce patrones que coinciden visualmente con módulos QR donde no debería haberlos. Para esas fotos el approach no alcanza y conviene reducir blend al 30 % o menos.

Cámaras de teléfonos viejos (anteriores a iOS 13 o Android 9) tienen decodificadores menos tolerantes. El QR puede escanear con la app oficial pero fallar en la cámara nativa. No probamos exhaustivamente en dispositivos viejos, lo decimos honestamente.

Iluminación al escanear importa. Un QR-art con poco contraste escanea bien en interior con luz pareja y falla bajo sol directo o en penumbra. Esto es una propiedad inherente al dithering, no específico de nuestra implementación. Lo comunicamos en la UI con una nota: "para mejores resultados, escanea en interior con luz uniforme".

QR con mucho contenido (más de 100 caracteres, versión 7+) tiene menos margen para arte. La densidad de módulos es alta, la zona crítica relativa al total es chica, y el dithering tiene poco espacio para respirar. El producto sugiere usar acortadores de URL cuando el contenido excede cierto largo.

Cuándo NO uses este approach

QR para pago (transferencias bancarias por QR, lectores de POS). Necesitan decodificación 100 % confiable en cualquier dispositivo, cualquier iluminación. Ahí va QR estándar negro sobre blanco, sin arte, sin negociación. La función primero.

QR para inventario, etiquetas industriales o trazabilidad donde el escaneo lo hace un lector dedicado (no cámara de celular). Esos lectores son menos tolerantes a ruido que las cámaras nativas actuales. Mantén el QR estándar.

QR que tiene que sobrevivir a impresión barata (papel térmico de ticket, fotocopia). Cada paso de impresión-decodificación introduce ruido propio. Un QR-art con poco margen de error puede no aguantar dos generaciones de fotocopia. QR estándar aguanta más.

Esta nota describe parte de cómo construimos aGo QR. Abre QR, pega una URL y arrastra una foto para ver el approach en vivo.

¿Necesitas algo parecido para tu producto? aGo lab construye software cliente puro para empresas. Conversemos.

§ CÓMO CITAR ESTE ARTÍCULO

aGo lab. (2026). "QR con dithering controlado para logo central". tools.ago.cl/notas/qr-dithering-controlado. Recuperado el 2026-05-25.

Esta nota es contenido propio de aGo lab y se publica bajo política de atribución requerida. Si la citas o referencias, incluye el link a la URL original.

§ aGo lab estudio

¿Quieres que aGo lab implemente esto para ti?

Si esta decisión técnica te resuelve un problema y necesitas aplicarla a un sistema propio, conversemos.

§ COMMAND

↑↓ navega · Enter abre · Esc cierra