I decided to redo a program that I did awhile ago that encodes text as an image.
For example, here's the entirety of
clojure/core.clj, Clojure's standard library:
And here's the source for the program itself (with a lower multiplier to make it more blue):
It's interesting that it's able to make use of PNG's image compression. The original text is 257KB, while the encoded image is only 148KB.
Basically is works by taking each letter from the string, getting its character code, multiplying it by a multiplier (for better color distribution), then turning it into a base-256 number in the form of a tuple of digits. That "number tuple" is then readily usable as a color tuple to be used in Pillow. It writes the pixels left-to-right, top-down, and attempts to make the resulting image as square as possible.
It's in two files. The first is helpers that handle turning a number to and from tuple representations:
>>> decimal_to_places(123, 2) # 123 to binary [1, 1, 1, 1, 0, 1, 1] >>> decimal_to_places(98765, 16) # 98765 to hexadecimal [1, 8, 1, 12, 13] # Then back again >>> places_to_decimal([1, 1, 1, 1, 0, 1, 1], 2) 123 >>> places_to_decimal([1, 8, 1, 12, 13], 16) 98765
And the second is the encoder/decoder.
My main concerns:
Use of Pillow. I have very little experience with the library, so I'd like to know if anything about my use here can be improved.
The base conversions. Is there a clean alternative to
decimal_to_placesthat's less "manual"?
And similarly with the encode/decode functions. They're both using fairly verbose "manual" looping.
I welcome critique regarding anything else though.
At the very bottom, I have a
test_encoding_decoding function that I used to do super-quick-and-dirty testing. It's far from my main concern, but I'd be open to comments on it as well.
from typing import Sequence, List import math def decimal_to_places(dec: int, base: int) -> List[int]: """Converts a decimal number into a list of digits in the given base. 123, 10 -> [1, 2, 3] 9, 2 -> [1, 0, 0, 1]""" remaining = dec max_power = int(math.log(dec, base)) acc =  for power in range(max_power, -1, -1): cur, remaining = divmod(remaining, base ** power) acc.append(cur) return acc def places_to_decimal(places: Sequence[int], base: int) -> int: """Converts a list of digits in the given base to decimal. [1, 2, 3], 10 -> 123 [1, 0, 0, 1], 2 -> 9""" return sum(place * (base ** power) for power, place in enumerate(reversed(places)))
from typing import Tuple, Iterator, List from PIL import Image, ImageDraw import math import base_conversion as bc EMPTY_PLACEHOLDER = (255, 255, 255) COLOR_BASE = 256 ALPHALESS_TUPLE_LENGTH = 3 def _square_dimensions(text_len: int) -> Tuple[int, int]: width = math.ceil(math.sqrt(text_len)) height = math.ceil(text_len / width) return width, height def _coordinates_in(width: int, height: int) -> Iterator[Tuple[int, int]]: for y in range(height): for x in range(width): yield x, y def _pad_color_list(color: List[int]) -> List[int]: needed = ALPHALESS_TUPLE_LENGTH - len(color) padded =  * needed padded.extend(color) return padded def encode_text(text: str, magnitude_multiplier: int = 1) -> Image.Image: """Returns an image where each character from the message is encoded as a colored pixel. Travels left-right, top-down. Will attempt to make the image as square as possible. magnitude_multiplier as multiplied by each character code prior to encoding.""" width, height = _square_dimensions(len(text)) img = Image.new("RGB", (width, height), EMPTY_PLACEHOLDER) graph = ImageDraw.Draw(img) for char, position in zip(text, _coordinates_in(width, height)): code = ord(char) * magnitude_multiplier color = bc.decimal_to_places(code, COLOR_BASE) padded_color = _pad_color_list(color) graph.point(position, tuple(padded_color)) return img def decode_image(img: Image.Image, magnitude_multiplier: int = 1) -> str: """Decodes an image produced by encode_text back into text. Each character code is divided by magnitude_multiplier after decoding.""" width, height = img.size decoded =  for position in _coordinates_in(width, height): color = img.getpixel(position) if color == EMPTY_PLACEHOLDER: break else: decoded_char_code = bc.places_to_decimal(color, COLOR_BASE) // magnitude_multiplier decoded.append(chr(decoded_char_code)) return "".join(decoded) SAMPLE_TEXT = "./clojure_core.clj" ENCODED_SAVE_PATH = "./encoded.png" # This is just a quick, messy way to test encoding/decoding def test_encoding_decoding(magnitude_multiplier: int, text_path: str = SAMPLE_TEXT, image_save_path: str = ENCODED_SAVE_PATH ) -> None: with open(text_path) as f: lines = f.readlines() orig_text = "\n".join(lines) img = encode_text(orig_text, magnitude_multiplier) img.show() decoded_text = decode_image(img, magnitude_multiplier) if orig_text == decoded_text: print("PASSED ENCODING") img.save(image_save_path) loaded_image = Image.open(ENCODED_SAVE_PATH) decoded_loaded = decode_image(loaded_image, magnitude_multiplier) if orig_text == decoded_loaded: print("PASSED LOADED DECODING") else: print("Failed loaded decoding...", len(orig_text), len(decoded_text)) else: print("Failed decoding...", len(orig_text), len(decoded_text))