Today’s post is about a not so useful tool that I built for fun. Long time ago, I wrote a program to convert project gutenberg ebooks to image files. I created a dictionary that mapped all ASCII characters to a unique pixel. This was then used to encode the story to an image. I remembered that program and thought I will spend some time building it again in Python to relieve old memories. See below for two example story books.

I overlayed two different ebooks here. One of them is The Adventures of Sherlock Holmes by Arthur Conan Doyle and the other one is Alice in Wonderland by Lewis Carol. Of course, the image size is bigger for Sherlock Holmes as it is a longer book.
This potentially can be classified as steganography, however in this case the entire image is the message. The key item needed for this feature is a reproducible dictionary. This dictionary was created using python random. However, I used a fixed seed to keep the dictionary values same. I also introduced two fixed values for space and new lines. Every other non-control ASCII character was mapped to a unique pixel.
Dictionary
Creating a dictionary is fairly easy. We will generate two of them, one for encryption and the other for decryption. The reason we create two is because it is a different key that we use for searching.
We start by defining a fixed random seed. This ensures that for the same seed, dictionary is same all the time.
def __init__(self, secret) -> None: random.seed(secret) self.enc_dict = {} self.dec_dict = {}
Keeping this as the starting point, now we generate the dictionaries.
def create_hash(self, r, g, b): r_str = str(r) g_str = str(g) b_str = str(b) rgb_str = r_str.rjust(3, '0') + g_str.rjust(3, '0') + b_str.rjust(3, '0') return rgb_str def create_dict(self): # Start character r = int(random.randrange(0, 255)) g = int(random.randrange(0, 255)) b = int(random.randrange(0, 255)) h = self.create_hash(r, g, b) self.enc_dict[SpecialChars.TEXT_START.value] = (r, g, b) self.dec_dict[h] = SpecialChars.TEXT_START.value # End character r = int(random.randrange(0, 255)) g = int(random.randrange(0, 255)) b = int(random.randrange(0, 255)) h = self.create_hash(r, g, b) self.enc_dict[SpecialChars.TEXT_END.value] = (r, g, b) self.dec_dict[h] = SpecialChars.TEXT_END.value # New Line r = int(random.randrange(0, 255)) g = int(random.randrange(0, 255)) b = int(random.randrange(0, 255)) h = self.create_hash(r, g, b) self.enc_dict[SpecialChars.NEWLINE.value] = (r, g, b) self.dec_dict[h] = SpecialChars.NEWLINE.value # Space r = int(random.randrange(0, 255)) g = int(random.randrange(0, 255)) b = int(random.randrange(0, 255)) h = self.create_hash(r, g, b) self.enc_dict[SpecialChars.SPACE.value] = (r, g, b) self.dec_dict[h] = SpecialChars.SPACE.value # ASCII range for x in range(33, 127): while True: r = int(random.randrange(0, 255)) g = int(random.randrange(0, 255)) b = int(random.randrange(0, 255)) h = self.create_hash(r, g, b) if not self.is_duplicate(h): break self.enc_dict[x] = (r, g, b) self.dec_dict[h] = x return self.enc_dict, self.dec_dict
This code is pretty much self-explanatory and evidently a lot of duplicate code. We start by creating default values for Start, End, Space and Newline. Then we create a RGB pixel value for all non-control ASCII characters between 33 and 127. Since color values always range between 0-255, that is what we kept our range to. Finally, we return the two dictionaries created.
On a side note, we can have this dictionary just have grayscale or colors within a specific palette. This will help in merging with background colors if any. For this project I did not care to build a specific color palette.
Encode and Save Image
After we are good with creation of dictionary, we start creating the image encoding part. We have used Pillow library to manipulate image pixels. The idea is fairly simple. We replace every letter with the corresponding pixel value and finally save the image.
One quick note of concern here. We have intentionally used PNG to save images. PNG image format will keep quality over size and so saved pixels are unaltered. JPG may alter pixels to make the image file smaller. As a result, we may not be able to decode properly.
def __init__(self) -> None: # Let's create the dictionary random.seed(self.encode_key) px = PixelDictionary(self.encode_key) self.pxlencdict, self.pxldecdict = px.create_dict() def encode_file(self, fname): pxl_value = [] char_cnt = 0 with open(fname, encoding = 'utf-8') as fp: pxl_value.append(self.pxlencdict.get(SpecialChars.TEXT_START.value)) char_cnt += 1 while c := fp.read(1): ccode = ord(c) if ccode in self.pxlencdict: char_cnt += 1 pxl_value.append(self.pxlencdict.get(ccode)) elif ccode == 32: char_cnt += 1 pxl_value.append(self.pxlencdict.get(SpecialChars.SPACE.value)) elif ccode == 13 or ccode == 10: char_cnt += 1 pxl_value.append(self.pxlencdict.get(SpecialChars.NEWLINE.value)) else: print('Character %c not found...' % c) pxl_value.append(self.pxlencdict.get(SpecialChars.TEXT_END.value)) char_cnt += 1 print('Character Count: %d' % char_cnt) return char_cnt, pxl_value def write_image(self, fname, char_cnt, pxl_data): # Is char count perfect square? rt = int(char_cnt ** (0.5)) if char_cnt == (rt*rt): im_width = rt im_height = rt else: im_width = rt + 1 im_height = rt + 1 im = Image.new(mode='RGB', size=(im_width, im_height)) cnt = 0 for y in range(im_height): for x in range(im_width): if cnt >= char_cnt: # Put Random Pixels r = int(random.randrange(0, 255)) g = int(random.randrange(0, 255)) b = int(random.randrange(0, 255)) im.putpixel((x, y), (r, g, b)) else: im.putpixel((x, y), pxl_data[cnt]) cnt += 1 im.save(fname)
Here we have broken encoding and writing to image in two separate functions. Encode_file function reads through the ebook and creates a list of pixels that will represent it. Finally, we can call write_image that writes the image to disk. We use PIL to manipulate images.
Given below is an image and associated text.

There once was a poor boy who spent his days going door-to-door selling newspapers to pay for school. One day, as he was walking his route, he started feeling low and weak. The poor boy was starving, so he decided to ask for food when he came to the next door.
The poor boy asked for food but was denied every time, until he reached the door of a girl. He asked for a glass of water, but seeing his poor state, the girl came back with a glass of milk. The boy asked how much he owed her for the milk, but she refused payment.
Years later, the girl, who was now a grown woman, fell sick. She went from doctor to doctor, but no one was able to cure her. Finally, she went to the best doctor in town.
The doctor spent months treating her until she was finally cured. Despite her happiness, she was afraid she couldn’t afford to pay the bill. But, when the hospital handed her the bill, it read, ‘Paid in full, with a glass of milk.’
Decode Text from Image
Decode will reverse the process of writing. We will start to decipher as soon as we get a START TEXT pixel and keep on reading till we get END TEXT pixel.
def decode_file(self, infname, outfname): is_writing = False im = Image.open(infname) im_width, im_height = im.size with open(outfname, 'w', encoding = 'utf-8') as fp: for y in range(im_height): for x in range(im_width): px_val = im.getpixel((x, y)) clr_hash = self.px.create_hash_tuple(px_val) if clr_hash in self.pxldecdict: pxl_val = self.pxldecdict.get(clr_hash) if pxl_val == SpecialChars.TEXT_START.value: is_writing = True elif pxl_val == SpecialChars.TEXT_END.value: is_writing = False else: if is_writing: if pxl_val == SpecialChars.NEWLINE.value: fp.write('\n') elif pxl_val == SpecialChars.SPACE.value: fp.write(' ') else: fp.write(chr(pxl_val))
This is also very simple code. We will iterate through all pixels and write the text value for corresponding pixels.
Simple Steganography
We can also use the code created above to hide small text messages in images. For example see the image below.

Image Courtesy: Kate Trush, Pexels.com
There is a hidden message in this image. You have to zoom in all the way to see it. I have added a zoomed in inset where the message is.
If you zoom in a lot, you will be able to see the data line. This can be modified in quite a few ways to as to make it more obfuscate it even more.
I just created a simple function to find a random location and add whatever text needs to be hidden. This ensures my decode code does not need any change.
def encode_file(self, txt, infile, outfile): im = Image.open(infile) im_width, im_height = im.size px_count = im_height * im_width # Get a Random point to start out our text rjmp = int(random.randrange(0, px_count - len(txt) - 2)) # Now just change the text px = im.load() ltxt = list(txt) px[self.point1d2d(rjmp, im_width)] = self.pxlencdict.get(SpecialChars.TEXT_START.value) inc = rjmp + 1 for x in range(len(txt)): ccode = ord(ltxt[inc-rjmp-1]) if ccode in self.pxlencdict: px[self.point1d2d(inc, im_width)] = self.pxlencdict.get(ccode) elif ccode == 32: px[self.point1d2d(inc, im_width)] = self.pxlencdict.get(SpecialChars.SPACE.value) elif ccode == 13 or ccode == 10: px[self.point1d2d(inc, im_width)] = self.pxlencdict.get(SpecialChars.NEWLINE.value) else: print('Character %c not found...' % chr(ccode)) inc += 1 px[self.point1d2d(inc, im_width)] = self.pxlencdict.get(SpecialChars.TEXT_END.value) im.save(outfile)
So, we just open the file, jump to the specific pixels and put the required text. It is useful in this case to use a palette that resembles the color palette for the image.
Conclusion
This was really created for fun. I do not think this has any practical use. Code will be available on my Github link. Ciao for now!