8
\$\begingroup\$

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))

enter image description here

And here's the source for the program itself (with a lower multiplier to make it more blue):

enter image description here

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))
\$\endgroup\$
1
  • \$\begingroup\$ Whoops, I actually meant to rename graph. That's left over from me thinking about this like I was using a Graphics Java object. \$\endgroup\$ Commented Dec 27, 2019 at 20:10

1 Answer 1

5
\$\begingroup\$

This is a really nice problem, thanks for sharing your solution!

One of the really nice things about this problem is that if you use Python 3, you can use binary data natively. In my code below, I use Python 3's binary data capabilities heavily.


The most important thing to note:

Base 256 in Python is binary data.

While Python does have natural bin, oct, and hex methods, what we really want to do here is translate our strings to bytes arrays. If there were no other requirements, then this problem could be solved in one function-- it's that simple. However, the multiplier requires some internal conversion, and so the code below will account for that using the same structure in the original code.

Some minor things:

  • Nice generator function. Fortunately, we can replicate the same behavior by importing itertools.product and carefully arranging the indices.
  • In line with the bytes discussion above, we no longer need the functions from base_conversion.py. Everything can be logically contained in one file.
  • In the original code, the string read from the file contained newlines '\n'. However, it was also using "\n".join(arr); meaning the resulting string had twice as many newlines. So if you immediately notice the images being different, I would imagine this is why.
  • There are two functions for encoding the text, encode_text_orig which retains the spirit of the original encode_text function and encode_text_np which utilizes numpy to structure the bytes array. I'll leave it up to you which one to use, both are functional.
from typing import Tuple
import math
from itertools import product
import numpy as np

from PIL import Image, ImageDraw

EMPTY_PLACEHOLDER = (255, 255, 255)
ALPHALESS_TUPLE_LENGTH = len(EMPTY_PLACEHOLDER)


def _square_dimensions(text_len: int, difference=False) -> Tuple[int, int]:
    width = math.ceil(math.sqrt(text_len))
    height = math.ceil(text_len / width)

    return width, height


def encode_text_orig(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, (x, y) in zip(text, product(range(height), range(width))):
        code = ord(char) * magnitude_multiplier
        color = code.to_bytes(ALPHALESS_TUPLE_LENGTH, 'big')

        graph.point((y, x), tuple(color))

    return img


def encode_text_np(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))

    arr = np.full((width, height, 3), 255, dtype=np.uint8)

    for char, (x, y) in zip(text, product(range(width), range(height))):
        code = char * magnitude_multiplier
        color = code.to_bytes(ALPHALESS_TUPLE_LENGTH, 'big')

        arr[x, y] = tuple(color)

    return Image.fromarray(arr)


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."""

    decoded = []
    for color in img.getdata():
        if color == EMPTY_PLACEHOLDER:
            break

        decoded.append((int.from_bytes(color, 'big') // magnitude_multiplier).to_bytes(1, 'big'))

    return b''.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, 'rb') as f:
        orig_text = f.read()

    img = encode_text_np(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))


if __name__ == '__main__':
    test_encoding_decoding(10)

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Ha, to_bytes + tuple is great. I can't believe I didn't know about that. Thanks. I'm going to leave this a couple days before I accept, but I appreciate the review. \$\endgroup\$ Commented Dec 27, 2019 at 23:44

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.