Home

Corrupting PNGs

Glitchart Jam on the Weekend

Glitchart is cool, I had an idea to take a PNG image, pick a random byte in it, change it to a random value, and then keep doing that over and over until the image breaks. Then the last working image should have some interesting glitch patterns.

I was working on this on a weekend with a friend (@NucleicAcidTest@tech.lgbt), who's also interested in glitchart and generative art. We decided to use an image generated with her genart project by combining gradients and performing mathematical operations on it. Here's the original:

original photo. a colorful gradient made up of black, blue, green, red, and yellow

We can start out by opening the image and reading its bytes.

with open(filename, 'rb') as f:
    image_data = f.read()

Next, choose a random byte from the image, pick a new random value for it, and save it again.

byte_index = random.randint(0, len(image_data))
new_value = random.randint(0, 256)

image_data = image_data[:byte_index] + new_value.to_bytes(1, 'big') + image_data[byte_index+1:]

file_path = f'{filename}_corrupted.png'
with open(file_path, 'wb') as f:
    f.write(image_data)

After changing one byte in the original, this was the result:

a corrupted, pixelated version of the above image

Nice! Now we could continue changing random bytes further. There was a problem though. When I tried sending the file to my friend, discord wasn't able to process it, it was just throwing an error. And every image viewer displayed that image slightly differently. We wanted to keep the corrupted patterns but make this file a valid PNG again.

This was the most time consuming part of the project, before we even had a chance to try changing a second byte. In summary, opening the image with Python's Pillow library didn't work because it would just panic when trying to read the image. Using ImageMagick also didn't work. We just needed something that could open the image, while still displaying all the cool corrupted patterns, and then save it containing those patterns as a new, valid file.

Willing to try anything at that point, she opened it in GIMP and it handled it perfectly. Then, after saving it again, the file was valid again! But that's not convenient at all... Opening each file manually with GIMP, then saving it, for every iteration of the script was super inefficient. Very doubltfully, I looked up if GIMP has a mode for executing operations from the command line...

And it does! And it's awful!

GIMP has its own scripting language, which has a syntax like nothing I ever used before, and comes straight from the 1990s. To use it, run gimp -i -b "script here", where the -i means "don't open the GUI" and the -b tells it that the following text is a GIMP script. Fortunately, we didn't need to dig too deep into it as we only needed it to open a file and save it in place.

After over an hour of looking through old obscure documentation pages, long forgotten forum posts, and thanks to this life saving blog post on GIMP's website, we ended up with this beauty:

(define (resave filename) 
    (let* (
        (image 
            (car (
                gimp-file-load RUN-NONINTERACTIVE filename filename)
            )
        ) 
        (drawable 
            (car 
                (gimp-image-get-active-layer image)
            )
        )
    ) 
    (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename)
    (gimp-image-delete image)
    )
) 
(resave "{filename}")

and as a command:

gimp -i -b '(define (resave filename) (let* ((image (car (gimp-file-load RUN-NONINTERACTIVE filename filename))) (drawable (car (gimp-image-get-active-layer image)))) (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename) (gimp-image-delete image))) (resave "{filename}")' -b '(gimp-quit 0)'

where instead of {filename} we put the name of the file we want to "resave". With that out of the way, and a python function that handles it (below), we were able to continue.

def gimp_resave(filename):
    print(f'{" gimp start ":=^50}')
    result = subprocess.run([
        'gimp',
        '-i',
        '-b',
        f'(define (resave filename) (let* ((image (car (gimp-file-load RUN-NONINTERACTIVE filename filename))) (drawable (car (gimp-image-get-active-layer image)))) (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename) (gimp-image-delete image))) (resave "{filename}")',
        '-b',
        '(gimp-quit 0)'
    ])
    print(result)  
    print(f'{" gimp end ":=^50}')
    print()

It was time to put what we had into an infinite loop.

folder = 'generated/'+filename[:filename.rfind('.')]
iteration = 1

while True:
    print(f'iteration {iteration}')

    byte_index = random.randint(0, len(image_data))
    new_value = random.randint(0, 256)

    print(f'    changing byte {byte_index}')
    print(f'    from {int(image_data[byte_index])} to {new_value}')

    image_data = image_data[:byte_index] + new_value.to_bytes(1, 'big') + image_data[byte_index+1:]

    print(f'    saving {iteration}.png')
    print()

    file_path = f'{folder}/{iteration}.png'
    with open(file_path, 'wb') as f:
        f.write(image_data)

    gimp_resave(file_path)

    with open(file_path, 'rb') as f:
        image_data = f.read()

    iteration += 1

    input("Continue?")

With that input() at the end so we can view the results one at a time. Here is what we got, with the program output and images interlaced for easier viewing.

[cqql@cqqlvoid genart]$ python3 mixbytes.py 2023-12-16_FFT_of_X__Y_2.png 
iteration 1
    changing byte 81238
    from 163 to 70
    saving 1.png

=================== gimp start ===================
GIMP-Message: Some fonts failed to load:
- /usr/share/gimp/2.0/fonts/

Error loading PNG file: IDAT: incorrect data check
batch command executed successfully
CompletedProcess(args=['gimp', '-i', '-b', '(define (resave filename) (let* ((image (car (gimp-file-load RUN-NONINTERACTIVE filename filename))) (drawable (car (gimp-image-get-active-layer image)))) (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename) (gimp-image-delete image))) (resave "generated/2023-12-16_FFT_of_X__Y_2 (7)/1.png")', '-b', '(gimp-quit 0)'], returncode=0)
==================== gimp end ====================

a corrupted, pixelated version of the original image

iteration 2
    changing byte 39554
    from 64 to 119
    saving 2.png

=================== gimp start ===================
GIMP-Message: Some fonts failed to load:
- /usr/share/gimp/2.0/fonts/

Error loading PNG file: bad adaptive filter value
batch command executed successfully
CompletedProcess(args=['gimp', '-i', '-b', '(define (resave filename) (let* ((image (car (gimp-file-load RUN-NONINTERACTIVE filename filename))) (drawable (car (gimp-image-get-active-layer image)))) (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename) (gimp-image-delete image))) (resave "generated/2023-12-16_FFT_of_X__Y_2 (7)/2.png")', '-b', '(gimp-quit 0)'], returncode=0)
==================== gimp end ====================

a corrupted, pixelated version of the original image

iteration 3
    changing byte 29856
    from 163 to 62
    saving 3.png

=================== gimp start ===================
GIMP-Message: Some fonts failed to load:
- /usr/share/gimp/2.0/fonts/

Error loading PNG file: IDAT: CRC error
batch command executed successfully
CompletedProcess(args=['gimp', '-i', '-b', '(define (resave filename) (let* ((image (car (gimp-file-load RUN-NONINTERACTIVE filename filename))) (drawable (car (gimp-image-get-active-layer image)))) (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename) (gimp-image-delete image))) (resave "generated/2023-12-16_FFT_of_X__Y_2 (7)/3.png")', '-b', '(gimp-quit 0)'], returncode=0)
==================== gimp end ====================

a corrupted, pixelated version of the original image

iteration 4
    changing byte 43117
    from 234 to 250
    saving 4.png

=================== gimp start ===================
GIMP-Message: Some fonts failed to load:
- /usr/share/gimp/2.0/fonts/

Error loading PNG file: IDAT: incorrect data check
batch command executed successfully
CompletedProcess(args=['gimp', '-i', '-b', '(define (resave filename) (let* ((image (car (gimp-file-load RUN-NONINTERACTIVE filename filename))) (drawable (car (gimp-image-get-active-layer image)))) (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename) (gimp-image-delete image))) (resave "generated/2023-12-16_FFT_of_X__Y_2 (7)/4.png")', '-b', '(gimp-quit 0)'], returncode=0)
==================== gimp end ====================

a corrupted, pixelated version of the original image

iteration 5
    changing byte 43483
    from 28 to 95
    saving 5.png

=================== gimp start ===================
GIMP-Message: Some fonts failed to load:
- /usr/share/gimp/2.0/fonts/

Error loading PNG file: bad adaptive filter value
batch command executed successfully
CompletedProcess(args=['gimp', '-i', '-b', '(define (resave filename) (let* ((image (car (gimp-file-load RUN-NONINTERACTIVE filename filename))) (drawable (car (gimp-image-get-active-layer image)))) (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename) (gimp-image-delete image))) (resave "generated/2023-12-16_FFT_of_X__Y_2 (7)/5.png")', '-b', '(gimp-quit 0)'], returncode=0)
==================== gimp end ====================

a corrupted, pixelated version of the original image

iteration 6
    changing byte 18020
    from 154 to 161
    saving 6.png

=================== gimp start ===================
GIMP-Message: Some fonts failed to load:
- /usr/share/gimp/2.0/fonts/

Error loading PNG file: IDAT: CRC error
batch command executed successfully
CompletedProcess(args=['gimp', '-i', '-b', '(define (resave filename) (let* ((image (car (gimp-file-load RUN-NONINTERACTIVE filename filename))) (drawable (car (gimp-image-get-active-layer image)))) (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename) (gimp-image-delete image))) (resave "generated/2023-12-16_FFT_of_X__Y_2 (7)/6.png")', '-b', '(gimp-quit 0)'], returncode=0)
==================== gimp end ====================

a corrupted, pixelated version of the original image

iteration 7
    changing byte 15233
    from 245 to 98
    saving 7.png

=================== gimp start ===================
GIMP-Message: Some fonts failed to load:
- /usr/share/gimp/2.0/fonts/

Error loading PNG file: bad adaptive filter value
batch command executed successfully
CompletedProcess(args=['gimp', '-i', '-b', '(define (resave filename) (let* ((image (car (gimp-file-load RUN-NONINTERACTIVE filename filename))) (drawable (car (gimp-image-get-active-layer image)))) (gimp-file-save RUN-NONINTERACTIVE image drawable filename filename) (gimp-image-delete image))) (resave "generated/2023-12-16_FFT_of_X__Y_2 (7)/7.png")', '-b', '(gimp-quit 0)'], returncode=0)
==================== gimp end ====================

a corrupted, pixelated version of the original image

It's working :)

The only thing left now is some exit condition, which for now we decided it should just be that if the last two images are the same, the loop will stop.

from PIL import Image

def compare_images(filename1, filename2):
    image1 = Image.open(filename1)
    image2 = Image.open(filename2)

    width, height = image1.size

    for x in range(width):
        for y in range(height):
            if image1.getpixel((x,y)) != image2.getpixel((x,y)):
                return False

    print('Images are the same')
    return True

And the proof of concept is done!

Here is the final python script: mixbytes.py (opens a preview)

You run it by giving it a file as the first commandline argument, for example:

python3 mixbytes.py "2023-12-16_FFT_of_X__Y_2.png"

There is so much more we can do with this. We were thinking about moving entire ranges of bytes by an offset, swapping two bytes' positions, reversing a range of bytes, and more. I will share the results of that too.

Home