Skip to content

Commit

Permalink
Add file cache for bypassing re-rendering images already created
Browse files Browse the repository at this point in the history
  • Loading branch information
Lorena Mesa committed Jul 17, 2024
1 parent 7ddbcbd commit 01ad24e
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 25 deletions.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Python Identicon Generator

A Python 3.10 CLI script that generates identicons. Default size for identicons created is 320X320 pixels, as this is the recommended size by many social media platforms like Instagram.

For help running the script use `python3 main.py -h` to retrieve a usage prompt and overview of parameters including optional parameters.

Usage:
1. Only providing the input `-t` text: `python3 main.py -t helloworld`.
2. Providing the input `-t` text and a specified output `-o` name for the ouput `*.png` identicon: `python3 main.py -t helloworld -o helloworld`.
3. Providing the input `-t` text and a specified output `-o` name for the ouput `*.png` identicon and overriding default dimensions of 320X320 pixels e.g. 150X150 pixels: `python3 main.py -t helloworld -o helloworld -d 150`.

## Problem Prompt

Users often work collaboratively in digital environments where a profile picture is not available. Some platforms have attempted to solve this problem with the creation of randomly generated, unique icons for each user ([github](https://github.blog/2013-08-14-identicons/), [slack](https://slack.zendesk.com/hc/article_attachments/360048182573/Screen_Shot_2019-10-01_at_5.08.29_PM.png), [ethereum wallets](https://github.com/ethereum/blockies)) sometimes called *Identicons*. Given an arbitrary string, create an image that can serve as a unique identifier for a user of a B2B productivity app like slack, notion, etc.
Expand All @@ -19,6 +28,7 @@ Users often work collaboratively in digital environments where a profile picture
1. The identicon's should be symmetrical meaning the left horizontal half is equal to the right horizontal half.
2. The identicon is 5X5 pixels, following the standard specified for [GitHub identicons](https://github.blog/2013-08-14-identicons/), so we'll generate square identicons only with a default of 320X320 pixels which other social media platforms like Instagram define as an ideal size
3. Identicon's should use proper resizing sampling technique to ensure quality is maintained, see [Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.resize) documentation for options
4. Avoid replicating creating identicon by confirming if an image for said user has been generated, if so retrieve from a persistence layer. A NoSQL solution like mongodb would be useful, but we'll use a modified version of `@lru_cache` from the `sweepai` project - `@file_cache` which persists pickled objects between runs.

## TODO:
- [X] Implement core logic to generate a Python PIL image
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
ruff
Pillow
Pillow
sweepai
41 changes: 31 additions & 10 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

import argparse
import hashlib
import io
from PIL import Image, ImageDraw
import time

from sweepai.logn.cache import file_cache

__author__ = "Lorena Mesa"
__email__ = "[email protected]"


class Identicon:

def __init__(self, input_str: str) -> None:
self.md5hash_str: str = self._convert_string_to_sha_hash(input_str)
def __init__(self) -> None:
self.md5hash_str: str = None
self.grid_size: int = 5
self.square_size: int = 64
self.identicon_size: tuple = (self.grid_size * self.square_size, self.grid_size * self.square_size)
Expand Down Expand Up @@ -58,18 +62,24 @@ def _generate_pixel_fill_color(self, md5hash_str: str) -> tuple:
"""
return tuple(int(md5hash_str[i:i+2], base=16) for i in range(0, 2*3, 2))

def render(self, filename: str=None, dimensions: int=0) -> Image:
@file_cache()
def render(self, input_str: str, filename: str="identicon", dimensions: int=0) -> Image:
"""
Function that generates a grid - a list of lists - indicating which pixels
are to be filled and uses the md5hash_str to generate an image fill color.
Function creates a PIL Image, drawing it, and saving it. By default a 320
pixel by 320 pixel identicon is rendered, if upon executing the code a
dimensions parameter is passed in the image will be resized.
:param input_str: unique identifer input string used to generate identicon
:param filename: filename of PIL png image generated
:return: None
"""

# Can uncomment to confirm the @file_cache is working
# import time; time.sleep(5)

self.md5hash_str = self._convert_string_to_sha_hash(input_str)
fill_color: tuple = self._generate_pixel_fill_color(self.md5hash_str)
grid: list[list] = self._build_grid()

Expand All @@ -96,19 +106,25 @@ def render(self, filename: str=None, dimensions: int=0) -> Image:
row * self.square_size + self.square_size
]
draw.rectangle(shape_coords, fill=fill_color)

if not filename:
filename: str = 'example'

if dimensions:
# Possible resampling filters here: https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.resize
# BICUBIC and LANCZOS take longer to process than NEAREST, but the quality of the former is better.
width_percent: float = (dimensions / float(image.size[0]))
height: int = int((float(image.size[1]) * float(width_percent)))
image = image.resize((dimensions, height), Image.Resampling.LANCZOS)

image.save(f'{filename}.png')

# Return a unique string with the input str value and the image bytes array
# to allow a cache hit

byteIO = io.BytesIO()
image.save(byteIO, format='PNG')
im_bytes = byteIO.getvalue()
# import pdb; pdb.set_trace()
return f'{input_str}_{im_bytes}'


if __name__ == '__main__':
parser = argparse.ArgumentParser(
Expand Down Expand Up @@ -140,7 +156,8 @@ def dimensions_gt_zero(input_dimensions: str):
"-o",
"--output",
type=len_gt_zero,
help="Name for output square identicon image generated.",
help="Name for output square identicon PNG image generated.",
default='identicon'
)
parser.add_argument(
"-d",
Expand All @@ -151,5 +168,9 @@ def dimensions_gt_zero(input_dimensions: str):

args = parser.parse_args()

identicon = Identicon(input_str=args.string)
identicon.render(filename=args.output, dimensions=args.dimensions)
# Add timer to confirm performance of code
t0 = time.time()
identicon = Identicon()
result = identicon.render(input_str=args.string, filename=args.output, dimensions=args.dimensions)
t1 = time.time()
print(f"{t1-t0} seconds to render {args.output}.png is now available to download!")
61 changes: 47 additions & 14 deletions test/sample_cases_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from os import remove
from pathlib import Path
import shutil
from PIL import Image, PngImagePlugin
import subprocess
import time
import unittest

from src.main import Identicon
Expand All @@ -28,19 +30,22 @@ def test_ui_fails_to_create_identicon_with_dimensions_lt_1(self):

class TestHappyPath(unittest.TestCase):
def test_successfully_creates_identicon(self):
identicon = Identicon("931D387731bBbC988B31220")
identicon.render(filename="output")
identicon = Identicon()
identicon.render(input_str="931D387731bBbC988B31220", filename="output")
generated_image = Image.open(f"{PROJECT_ROOT}/output.png", mode="r")
self.assertIsInstance(generated_image, PngImagePlugin.PngImageFile)

# Cleanup
remove(f"{PROJECT_ROOT}/output.png")
shutil.rmtree("/tmp/file_cache")

def test_successfully_creates_same_identicon_for_same_input_strings(self):
# Make 1st identicon
identicon_john_1 = Identicon("john")
identicon_john_1.render(filename="john1")
identicon_john_1 = Identicon()
identicon_john_1.render(input_str="john", filename="john1")
# Make 2nd identicon
identicon_john_2 = Identicon("john")
identicon_john_2.render(filename="john2")
identicon_john_2 = Identicon()
identicon_john_2.render(input_str="john", filename="john2")

# Assertions
generated_john_1 = Image.open(f"{PROJECT_ROOT}/john1.png", mode="r")
Expand All @@ -54,14 +59,15 @@ def test_successfully_creates_same_identicon_for_same_input_strings(self):
# Cleanup
remove(f"{PROJECT_ROOT}/john1.png")
remove(f"{PROJECT_ROOT}/john2.png")
shutil.rmtree("/tmp/file_cache")

def test_does_not_create_same_identicon_for_different_input_strings(self):
# Make 1st identicon
identicon_john = Identicon("john")
identicon_john.render(filename="john")
identicon_john = Identicon()
identicon_john.render(input_str="john", filename="john")
# Make 2nd identicon
identicon_john_2 = Identicon("jane")
identicon_john_2.render(filename="jane")
identicon_john_2 = Identicon()
identicon_john_2.render(input_str="jane", filename="jane")

# Assertions
generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r")
Expand All @@ -75,10 +81,11 @@ def test_does_not_create_same_identicon_for_different_input_strings(self):
# Cleanup
remove(f"{PROJECT_ROOT}/john.png")
remove(f"{PROJECT_ROOT}/jane.png")
shutil.rmtree("/tmp/file_cache")

def test_successfully_resizes_identicon_gt_default_when_dimensions_provided(self):
identicon_john = Identicon("john")
identicon_john.render(filename="john", dimensions=450)
identicon_john = Identicon()
identicon_john.render(input_str="john", filename="john", dimensions=450)

# Assertions
generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r")
Expand All @@ -87,18 +94,44 @@ def test_successfully_resizes_identicon_gt_default_when_dimensions_provided(self

# Cleanup
remove(f"{PROJECT_ROOT}/john.png")
shutil.rmtree("/tmp/file_cache")

def test_successfully_resizes_identicon_lt_default_when_dimensions_provided(self):
identicon_john = Identicon("john")
identicon_john.render(filename="john", dimensions=150)
identicon_john = Identicon()
identicon_john.render(input_str="john", filename="john", dimensions=150)

# Assertions
# import pdb; pdb.set_trace()
generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r")
self.assertIsInstance(generated_john, PngImagePlugin.PngImageFile)
self.assertEqual(generated_john.size, (150, 150))

# Cleanup
remove(f"{PROJECT_ROOT}/john.png")
shutil.rmtree("/tmp/file_cache")

class TestFileCache(unittest.TestCase):
def test_successfully_skips_cache_if_identicon_already_made(self):
# Call first time to instantiante in the file cache
t0 = time.time()
identicon_johnny = Identicon()
identicon_johnny.render(input_str="johnny", filename="johnny", dimensions=150)
t1 = time.time()
johnny_1_time = t1 - t0

# Call second time to retrieve from the file cache
t0 = time.time()
identicon_johnny_2 = Identicon()
identicon_johnny_2.render(input_str="johnny", filename="johnny", dimensions=150)
t1 = time.time()
johnny_2_time = t1 - t0

# Assertions
self.assertLess(johnny_2_time, johnny_1_time)

# Cleanup
remove(f"{PROJECT_ROOT}/johnny.png")
shutil.rmtree("/tmp/file_cache")

if __name__ == "__main__":
unittest.main()

0 comments on commit 01ad24e

Please sign in to comment.