I'm trying to use the approach described on this paper to "watermark" a QR Code with a special payload.
The whole flow seems to be correct, but I'm having some trouble saving the payload as bytes to be xor'd against the QR Code
import qrcode
from PIL import Image, ImageChops
qr = qrcode.QRCode(
version=5,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=10,
border=2,
)
msg = "25.61795.?000001?.907363.02"
sct = "secret message test"
def save_qrcode(data, path):
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img.save(path)
save_qrcode(msg, "out.png")
save_qrcode(sct, "out2.png")
pure_qr_code = Image.open("out.png")
encoded_data_as_img = Image.new(mode=pure_qr_code.mode, size=pure_qr_code.size)
encoded_data_pre_xor = [ord(e) for e in sct]
print(encoded_data_pre_xor)
# Encoding
encoded_data_as_img.putdata(encoded_data_pre_xor)
encoded_data_as_img.save("out2.png")
encoded_data_as_img = Image.open("out2.png")
result = ImageChops.logical_xor(pure_qr_code, encoded_data_as_img)
result.save("result.png")
# Decoding
result = Image.open("result.png")
result2 = ImageChops.logical_xor(result, pure_qr_code)
result2.save("result2.png")
img_data_as_bytes = Image.open("result2.png").getdata()
encoded_data_after_xor = []
i = 0
while img_data_as_bytes[i]:
encoded_data_after_xor.append(img_data_as_bytes[i])
i += 1
print(encoded_data_after_xor)
This gives me the following output:
[115, 101, 99, 114, 101, 116, 32, 109, 101, 115, 115, 97, 103, 101, 32, 116, 101, 115, 116]
[255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
But trying to diff result2.png
and out2.png
returns a no-diff. This means the problem happens on the message to image saving
encoded_data_as_img.putdata(encoded_data_pre_xor)
You are doing it wrong, the message should be contained by the QR not the image, if you diff the images from your solution, only the first line of pixels (of the 390x390 image) get changed and that data will be lost if noise is applied (as shown in the figure 12). The other error is that you are assuming that the QR can be damaged equally on all the parts, for example, if the position part is damaged as much as the data can tolerate, the QR will not be detected.
The paper does not give an example but it simplifies to Damaged_QR = QR ⊕ String
assuming that 1 pixel = 1 cell, in python it is a bit more involved as you can't easily xor raw buffers, for that 2 functions are needed to expand the bits of a string to a list of bools and viceversa.
from PIL import Image, ImageChops
from pyzbar import pyzbar
import qrcode
def str_to_bool(text, char_bits=8):
ret = []
for c in text:
for i in range(char_bits):
ret.append(ord(c) & (1 << i) != 0)
return ret
def bool_to_str(bool_list, char_bits=8):
ret = ''
for ii in range(len(bool_list) // char_bits):
x = 0
for i in range(char_bits):
if bool_list[ii * char_bits + i]:
x |= 1 << i
if x != 0:
ret += chr(x)
return ret
def matrix_x_y(matrix, off_y_h=10, off_y_l=10):
for y in range(off_y_h, len(matrix) - off_y_l):
for x in range(len(matrix[y])):
yield x, y
To add the message it's as simple as xoring a segment of the data of the QR, here a char is 8 bits, but it can be compacted to 5 if only upper case text is needed.
original_data = 'Text' * 10
original_secret = 'Super Secret !!!'
qr = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_H,
)
qr.add_data(original_data)
qr.make()
original = qr.make_image()
original.save('original.png')
cursor, msg = 0, str_to_bool(original_secret)
for x, y in matrix_x_y(qr.modules):
qr.modules[y][x] ^= msg[cursor]
cursor += 1
if cursor >= len(msg):
break
modified = qr.make_image()
modified.save('modified.png')
To get the data back the QR needs to be read and re-created without the "damage" to then xor those two to get the data that can be converted back to a string.
decoded_data = pyzbar.decode(
Image.open('modified.png')
)[0].data.decode('utf-8')
redo = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_H,
)
redo.add_data(decoded_data)
redo.make()
nums = []
for x, y in matrix_x_y(qr.modules):
nums.append(qr.modules[y][x] ^ redo.modules[y][x])
decoded_secret = bool_to_str(nums)
Then just verify that it worked, if QR gets actual corruption the message will get corrupted too and depending on how much error margin is left the QR might not even be readable.
diff = ImageChops.difference(
Image.open('original.png'),
Image.open('modified.png')
)
diff.save('diff.png')
print('Valid QR: {0}'.format(
original_data == decoded_data
))
print('Valid Secret: {0}'.format(
original_secret == decoded_secret
))
To the naked eye there is no difference from the original (the first one) and the other one, if you just want to use this as a water mark there is no need to obfuscate the data.