Cómo funcionan internamente los códigos QR: generación e interpretación
Un código QR no es simplemente una imagen con cuadrados negros y blancos. Es una estructura de datos definida por reglas precisas. Un generador transforma una secuencia de datos en una matriz de módulos, mientras que un intérprete analiza esa matriz y reconstruye la información original. Ambos procesos pueden implementarse directamente a partir de la especificación del formato, sin recurrir a bibliotecas especializadas.
1. Representación del QR
Un QR está formado por una matriz cuadrada. Cada posición de la matriz es un módulo que puede tener uno de dos estados:
0 = blanco
1 = negro
El tamaño de la matriz depende de la versión del código.
Versión 1 → 21 × 21
Versión 2 → 25 × 25
Versión 3 → 29 × 29
...
El tamaño aumenta en cuatro módulos por cada versión:
tamaño = 21 + 4 × (versión - 1)
Por ejemplo:
def qr_size(version):
return 21 + 4 * (version - 1)
print(qr_size(1)) # 21
print(qr_size(2)) # 25
La matriz completa no contiene únicamente los datos. Una parte está reservada para funciones estructurales del símbolo.
2. Patrones de posición
Los tres patrones cuadrados grandes de las esquinas permiten localizar el QR y establecer su orientación.
Conceptualmente tienen una estructura similar a:
1111111
1000001
1011101
1011101
1011101
1000001
1111111
Una posible implementación de estos patrones sería:
def draw_finder(matrix, x, y):
for dy in range(-1, 8):
for dx in range(-1, 8):
px = x + dx
py = y + dy
if not (0 <= py < len(matrix)):
continue
if not (0 <= px < len(matrix)):
continue
# Separación exterior
if dx == -1 or dx == 7 or dy == -1 or dy == 7:
matrix[py][px] = 0
# Borde exterior
elif dx == 0 or dx == 6 or dy == 0 or dy == 6:
matrix[py][px] = 1
# Interior
elif 2 <= dx <= 4 and 2 <= dy <= 4:
matrix[py][px] = 1
else:
matrix[py][px] = 0
En una implementación real hay que distinguir entre módulos funcionales y módulos que posteriormente recibirán datos. Por eso normalmente se mantiene otra matriz que indique qué posiciones ya están ocupadas.
matrix = [[0 for _ in range(21)] for _ in range(21)]
reserved = [[False for _ in range(21)] for _ in range(21)]
matrix almacena los valores y reserved indica qué posiciones no pueden ser utilizadas para datos.
3. Conversión de datos a bits
Supongamos que queremos almacenar:
HELLO
El generador debe transformar los caracteres en una secuencia de bits.
Por ejemplo, usando una representación de 8 bits por carácter:
texto = "HELLO"
bits = []
for caracter in texto:
valor = ord(caracter)
for i in range(7, -1, -1):
bits.append((valor >> i) & 1)
print(bits)
La operación:
(valor >> i) & 1
extrae individualmente cada bit.
Sin embargo, un QR real no simplemente coloca los bytes del texto dentro de la matriz. Primero debe indicar el modo de codificación, la longitud de los datos y otros campos definidos por el formato.
Una representación simplificada podría comenzar así:
modo
longitud
datos
Por ejemplo:
0100 ........ ........
donde el valor inicial depende del modo utilizado. En el modo byte, los datos se representan como bytes.
4. Terminación y capacidad
La cantidad de bits que puede almacenar una versión depende del nivel de corrección de errores y del modo utilizado.
Si los datos ocupan menos espacio que la capacidad disponible, se agregan bits de terminación y, cuando es necesario, bits de relleno.
Conceptualmente:
while len(bits) < capacidad:
if len(bits) + 4 <= capacidad:
bits.extend([0, 0, 0, 0])
else:
bits.append(0)
Después pueden agregarse bytes de relleno alternados, según las reglas correspondientes.
Lo importante es que el generador debe conocer exactamente cuántos bits de datos y corrección caben en la versión seleccionada.
5. Corrección de errores mediante Reed-Solomon
Esta es una de las partes matemáticamente más interesantes del QR.
El QR utiliza códigos Reed-Solomon sobre un campo finito. En términos simplificados, los datos se convierten en coeficientes de un polinomio y después se calcula otro polinomio que genera bytes adicionales de redundancia.
El cálculo se realiza sobre:
GF(256)
Esto significa que cada elemento representa un byte y las operaciones se realizan utilizando aritmética de campo finito.
La multiplicación no puede hacerse simplemente como:
a * b
porque después de cierto punto se debe reducir el resultado utilizando un polinomio irreducible.
Una implementación básica de multiplicación en GF(256) puede escribirse así:
def gf_mul(a, b):
result = 0
while b:
if b & 1:
result ^= a
a <<= 1
if a & 0x100:
a ^= 0x11D
b >>= 1
return result
Aquí aparece un detalle importante:
a ^= 0x11D
El valor 0x11D corresponde al polinomio utilizado para construir el campo GF(256) empleado por QR.
Por ejemplo:
print(gf_mul(2, 3))
realiza una multiplicación dentro del campo, no una multiplicación entera convencional.
6. Generación de los bytes de corrección
Los bytes de corrección se obtienen mediante división polinómica.
Una representación conceptual es:
def polynomial_multiply(a, b):
result = [0] * (len(a) + len(b) - 1)
for i, x in enumerate(a):
for j, y in enumerate(b):
result[i + j] ^= gf_mul(x, y)
return result
A partir de allí se construye el polinomio generador y se divide el polinomio de datos entre él.
El residuo de esa división constituye los bytes de corrección.
datos
↓
polinomio
↓
división en GF(256)
↓
residuo
↓
bytes de corrección
El resultado final no contiene solamente:
DATOS
sino algo conceptualmente parecido a:
DATOS + CORRECCIÓN
Esto permite recuperar información aunque algunos módulos no puedan leerse correctamente.
7. Colocación de los bits en la matriz
Una vez preparados los bytes, sus bits deben colocarse en las posiciones disponibles.
Los bits no se escriben simplemente de izquierda a derecha.
El QR utiliza un recorrido específico por columnas verticales, normalmente trabajando en pares de columnas y alternando la dirección:
↑
↓
↑
↓
Una versión simplificada de la idea puede expresarse así:
row = size - 1
direction = -1
column = size - 1
while column > 0:
if column == 6:
column -= 1
for _ in range(size):
for x in [column, column - 1]:
if not reserved[row][x]:
if bit_index < len(bits):
matrix[row][x] = bits[bit_index]
bit_index += 1
row += direction
if row < 0 or row >= size:
row -= direction
direction = -direction
break
column -= 2
El código completo debe manejar además los cambios de dirección y las posiciones reservadas de acuerdo con la versión del símbolo.
8. Las máscaras
Después de colocar los datos, existe otro problema. Una distribución determinada de bits puede producir patrones visualmente problemáticos, por ejemplo grandes áreas uniformes o patrones que se parecen a los patrones de detección.
Por esta razón, QR define ocho máscaras.
Una máscara no cambia el significado de los datos. Cambia la representación de algunos módulos mediante una operación XOR.
Una de las reglas de máscara tiene una forma conceptual como:
def mask_0(row, col):
return (row + col) % 2 == 0
Si la condición es verdadera:
matrix[row][col] ^= 1
Se generan distintas versiones de la matriz y se evalúa cuál produce una penalización menor según las reglas del estándar.
Por tanto, el generador realmente debe realizar algo parecido a:
Datos
↓
Corrección de errores
↓
Colocación
↓
Máscara 0 ─┐
Máscara 1 │
Máscara 2 │
Máscara 3 ├── Evaluación
Máscara 4 │
Máscara 5 │
Máscara 6 │
Máscara 7 ─┘
↓
Seleccionar mejor máscara
↓
QR final
9. Cómo interpreta un QR un lector
El proceso de lectura comienza con una imagen, no con una matriz perfecta.
Supongamos que tenemos una imagen representada como valores de intensidad:
pixel = 0 # negro
pixel = 255 # blanco
Primero debe convertirse en una imagen binaria.
Una forma sencilla de expresar una umbralización es:
def threshold(pixel):
return 1 if pixel < 128 else 0
Sin embargo, una cámara real introduce problemas como iluminación desigual, perspectiva, ruido y desenfoque. Por eso un lector práctico necesita una etapa de procesamiento de imagen antes de interpretar la matriz.
10. Detección de los patrones
El lector busca estructuras que se parezcan al patrón de posición:
███████
█ █
█ ███ █
█ ███ █
█ ███ █
█ █
███████
La relación geométrica entre las regiones blancas y negras proporciona una característica que puede utilizarse para localizar posibles patrones de búsqueda.
Una vez encontrados tres patrones compatibles, el lector puede estimar:
posición
escala
orientación
perspectiva
Por ejemplo, si los puntos detectados son:
A = esquina superior izquierda
B = esquina superior derecha
C = esquina inferior izquierda
puede construirse un sistema de coordenadas local para transformar la imagen inclinada en una cuadrícula cuadrada.
11. Transformación de perspectiva
Supongamos que el QR está inclinado.
La cámara puede observar algo parecido a:
+----------------+
/ /
/ /
+----------------+
pero internamente el lector necesita recuperar:
+----------------+
| |
| |
| |
+----------------+
Esto se resuelve mediante una transformación proyectiva.
Matemáticamente puede expresarse mediante una matriz de homografía:
[x']
[y']
[w'] =
[ h11 h12 h13 ]
[ h21 h22 h23 ]
[ h31 h32 h33 ]
[x ]
[y ]
[ 1 ]
Después:
x = x' / w'
y = y' / w'
La transformación permite convertir coordenadas de la imagen observada en coordenadas correspondientes a la cuadrícula del QR.
12. Reconstrucción de la matriz
Cuando la perspectiva ha sido corregida, el lector debe determinar el tamaño del símbolo.
Por ejemplo:
21 × 21
25 × 25
29 × 29
Después muestrea cada posición.
Conceptualmente:
for row in range(size):
for col in range(size):
x, y = module_to_image(row, col)
matrix[row][col] = threshold(
image[y][x]
)
El resultado vuelve a ser una matriz binaria:
0 1 1 1 1 1 0 ...
0 1 0 0 0 1 0 ...
...
Ahora el lector ya no trabaja principalmente con la fotografía. Trabaja con la estructura lógica reconstruida.
13. Lectura de los datos
El lector identifica primero los módulos funcionales y los ignora al extraer los datos.
Después reproduce el mismo recorrido utilizado durante la generación.
Por ejemplo:
bits = []
for posicion in recorrido_datos:
if not reserved[posicion.row][posicion.col]:
bit = matrix[
posicion.row
][
posicion.col
]
bits.append(bit)
Pero existe un detalle importante: la matriz todavía está enmascarada.
Por eso debe aplicarse nuevamente la máscara correcta.
if mask_condition(row, col):
bit ^= 1
Entonces los bits vuelven a representar los valores originales.
14. Reconstrucción de los bytes
Los bits se agrupan nuevamente en bytes:
bytes_data = []
for i in range(0, len(bits), 8):
value = 0
for bit in bits[i:i + 8]:
value = (value << 1) | bit
bytes_data.append(value)
Por ejemplo:
01001000
se convierte en:
72
que corresponde al byte hexadecimal:
0x48
y, bajo una codificación compatible, al carácter:
H
15. Corrección de errores durante la lectura
Si existen módulos incorrectos, los bytes obtenidos pueden contener errores.
Aquí vuelve a intervenir Reed-Solomon.
El lector calcula los síndromes del bloque recibido:
datos recibidos
↓
síndromes
↓
localización de errores
↓
magnitud de errores
↓
corrección
↓
datos originales
Esta parte requiere operaciones adicionales sobre GF(256), incluyendo cálculo de polinomios de localización de errores y recuperación de las magnitudes de los errores.
Por eso la corrección de errores no consiste simplemente en comparar los bytes con una copia anterior. La redundancia matemática permite determinar qué posiciones son probablemente incorrectas y reconstruirlas.
16. Decodificación final
Una vez corregidos los bytes, el lector analiza la estructura de los datos.
Conceptualmente:
mode = read_mode(bits)
length = read_length(bits, mode)
data = read_data(
bits,
mode,
length
)
El modo indica cómo deben interpretarse los bits siguientes. Dependiendo del modo, el contenido puede representar caracteres, números u otros formatos contemplados por la especificación.
El proceso completo puede resumirse así:
GENERADOR
Texto
↓
Codificación
↓
Bits
↓
Bytes
↓
Reed-Solomon
↓
Datos + corrección
↓
Colocación en matriz
↓
Máscara
↓
Información de formato
↓
QR
INTÉRPRETE
Imagen
↓
Binarización
↓
Detección de patrones
↓
Estimación geométrica
↓
Corrección de perspectiva
↓
Muestreo de módulos
↓
Lectura de formato
↓
Eliminación de máscara
↓
Extracción de bits
↓
Bytes
↓
Reed-Solomon
↓
Corrección
↓
Decodificación
↓
Texto original
La diferencia esencial es que un generador conoce los datos antes de construir la matriz, mientras que un intérprete debe reconstruir esos datos a partir de una representación visual que puede contener errores. Por eso un lector de QR combina procesamiento geométrico, operaciones binarias, teoría de códigos y aritmética sobre campos finitos. Generar un QR puede parecer una tarea gráfica, pero en realidad consiste principalmente en construir una estructura de datos bajo reglas estrictas. Interpretarlo requiere realizar el proceso inverso y, además, recuperar la información cuando parte de la representación visual ha sido alterada.
© Arturo Juárez Monroy