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:
encode_text(clojure_core_text, int(5e4))
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_places
that'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.
base_conversion.py
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)))
encoder.py
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 = [0] * 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))
graph
. That's left over from me thinking about this like I was using aGraphics
Java object. \$\endgroup\$