From 7ddbcbdf00a9bc704479263ea9486e3805bfa9df Mon Sep 17 00:00:00 2001 From: Lorena Mesa Date: Wed, 17 Jul 2024 16:01:16 +0000 Subject: [PATCH] Update sample cases to confirm resizing works when less than default 320 by 320 dimensions --- README.md | 9 ++-- src/main.py | 87 ++++++++++++++++++++++----------------- test/sample_cases_test.py | 27 ++++++++---- 3 files changed, 72 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 299bc62..4aea21a 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,11 @@ Users often work collaboratively in digital environments where a profile picture ## Identicon Requirements 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 250X250 pixels -3. Identicon's should use accessible colors as specified by [W3](https://www.w3.org/WAI/WCAG21/Techniques/general/G207) +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 ## TODO: -- [ ] Finish script to implement identicon with multiple colors -- [X] Implement core logic to generate a Python PIL or Tinkr image +- [X] Implement core logic to generate a Python PIL image - [X] Write baseline tests -- [ ] Add CI/CD with GitHub actions to run tests +- [X] Add CI/CD with GitHub actions to run tests - [X] Add CI/CD with GitHub Actions to run linter diff --git a/src/main.py b/src/main.py index 6bf7375..8bae1a0 100644 --- a/src/main.py +++ b/src/main.py @@ -12,6 +12,9 @@ class Identicon: def __init__(self, input_str: str) -> None: self.md5hash_str: str = self._convert_string_to_sha_hash(input_str) + 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) def _convert_string_to_sha_hash(self, input_str: str) -> str: """ @@ -27,74 +30,82 @@ def _convert_string_to_sha_hash(self, input_str: str) -> str: def _build_grid(self) -> list[list]: """ Function that takes an input md5 hexdigest string and builds - a list of lists using grid size to determine the size of the - grid. Each value within the list of lists contains a row of booleans - that indicates if that given element will be filled with a color. + a list of lists using self.GRID_SIZE to determine the size of the + grid; with the default set to a 5X5 grid. + + Each value within the list of lists contains a row of booleans + that indicates if that given pizel will be filled with a color. - :return: a list of lists representing a grid of the pixels to be drawn in a PIL Image + :return: a list of lists representing a grid of the pixels to be + drawn and filled in a PIL Image """ - grid_size: int = 5 grid: list = [] - for row_number in range(grid_size): + for row_number in range(self.grid_size): row: list = list() - for element_number in range(grid_size): - element: int = row_number * grid_size + element_number + 6 - fill_element: bool = int(self.md5hash_str[element], base=16) % 2 == 0 + for pixel in range(self.grid_size): + current_pixel: int = row_number * self.grid_size + pixel + 6 + fill_element: bool = int(self.md5hash_str[current_pixel], base=16) % 2 == 0 row.append(fill_element) grid.append(row) return grid - def _generate_image_fill_color(self, md5hash_str: str) -> tuple: + def _generate_pixel_fill_color(self, md5hash_str: str) -> tuple: """ - Function that generates a R,G,B value to use to fill the PIL Image. + Function that generates a R,G,B value to use to fill the PIL Image pixels. :param md5hash_str: md5 hexdigest of an input string - :return: a tuple of numbers representing the R,G.B value to fill the PIL Image + :return: a tuple of numbers representing the R,G,B value to fill the PIL Image pixels """ return tuple(int(md5hash_str[i:i+2], base=16) for i in range(0, 2*3, 2)) - def draw_image(self, filename: str=None, dimensions: int=0) -> Image: + def render(self, filename: str=None, 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 250 pixel by 250 pixel identicon is created, if upon executing the code - a dimensions parameter is passed in the image will be resized. + 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 filename: filename of PIL png image generated :return: None """ - fill_color: tuple = self._generate_image_fill_color(self.md5hash_str) + fill_color: tuple = self._generate_pixel_fill_color(self.md5hash_str) grid: list[list] = self._build_grid() - # Default to a 250X250 pixel image - SQUARE: int = 50 - size: tuple = (5 * 50, 5 * 50) - bg_color: tuple = (214,214,214) - - image: Image = Image.new("RGB", size, bg_color) + # Default to a 320X320, a recommended avtar size per social platforms like Instagram, + # pixel image where each shape filled within the identicon is of size 64 pixels + background_color: tuple = (214,214,214) + image: Image = Image.new("RGB", self.identicon_size, background_color) draw: ImageDraw = ImageDraw.Draw(image) - # Makes the identicon symmetrical - for i in range(5): - grid[i][4] = grid[i][0] - grid[i][3] = grid[i][1] + # Makes the identicon symmetrical by setting the right columns + # values to the same as the left columns, minus the center column + for i in range(self.grid_size): + grid[i][self.grid_size - 1] = grid[i][0] + grid[i][self.grid_size - 2] = grid[i][1] - for row in range(5): - for element in range(5): + for row in range(self.grid_size): + for pixel in range(self.grid_size): # Boolean check to confirm 'True' to draw and fill the pixel in the iamge - if grid[row][element]: - bounding_box: list[int] = [element * SQUARE, row * SQUARE, element * SQUARE + SQUARE, row * SQUARE + SQUARE] - # TODO: Should we use multiple fill colors? May need to draw multiple rectangles to obtain this - draw.rectangle(bounding_box, fill=fill_color) + if grid[row][pixel]: + shape_coords: list[int] = [ + pixel * self.square_size, + row * self.square_size, + pixel * self.square_size + self.square_size, + row * self.square_size + self.square_size + ] + draw.rectangle(shape_coords, fill=fill_color) if not filename: filename: str = 'example' if dimensions: - wpercent: float = (dimensions / float(image.size[0])) - hsize: int = int((float(image.size[1]) * float(wpercent))) - image = image.resize((dimensions, hsize), Image.Resampling.LANCZOS) + # 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') @@ -141,4 +152,4 @@ def dimensions_gt_zero(input_dimensions: str): args = parser.parse_args() identicon = Identicon(input_str=args.string) - identicon.draw_image(filename=args.output, dimensions=args.dimensions) + identicon.render(filename=args.output, dimensions=args.dimensions) diff --git a/test/sample_cases_test.py b/test/sample_cases_test.py index b05677e..2e16707 100644 --- a/test/sample_cases_test.py +++ b/test/sample_cases_test.py @@ -29,7 +29,7 @@ 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.draw_image(filename="output") + identicon.render(filename="output") generated_image = Image.open(f"{PROJECT_ROOT}/output.png", mode="r") self.assertIsInstance(generated_image, PngImagePlugin.PngImageFile) remove(f"{PROJECT_ROOT}/output.png") @@ -37,10 +37,10 @@ def test_successfully_creates_identicon(self): def test_successfully_creates_same_identicon_for_same_input_strings(self): # Make 1st identicon identicon_john_1 = Identicon("john") - identicon_john_1.draw_image(filename="john1") + identicon_john_1.render(filename="john1") # Make 2nd identicon identicon_john_2 = Identicon("john") - identicon_john_2.draw_image(filename="john2") + identicon_john_2.render(filename="john2") # Assertions generated_john_1 = Image.open(f"{PROJECT_ROOT}/john1.png", mode="r") @@ -58,10 +58,10 @@ def test_successfully_creates_same_identicon_for_same_input_strings(self): def test_does_not_create_same_identicon_for_different_input_strings(self): # Make 1st identicon identicon_john = Identicon("john") - identicon_john.draw_image(filename="john") + identicon_john.render(filename="john") # Make 2nd identicon identicon_john_2 = Identicon("jane") - identicon_john_2.draw_image(filename="jane") + identicon_john_2.render(filename="jane") # Assertions generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r") @@ -76,18 +76,29 @@ def test_does_not_create_same_identicon_for_different_input_strings(self): remove(f"{PROJECT_ROOT}/john.png") remove(f"{PROJECT_ROOT}/jane.png") - def test_successfully_resizes_identicon_gt_250_when_dimensions_provided(self): + def test_successfully_resizes_identicon_gt_default_when_dimensions_provided(self): identicon_john = Identicon("john") - identicon_john.draw_image(filename="john", dimensions=300) + identicon_john.render(filename="john", dimensions=450) # Assertions generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r") self.assertIsInstance(generated_john, PngImagePlugin.PngImageFile) - self.assertEqual(generated_john.size, (300, 300)) + self.assertEqual(generated_john.size, (450, 450)) # Cleanup remove(f"{PROJECT_ROOT}/john.png") + def test_successfully_resizes_identicon_lt_default_when_dimensions_provided(self): + identicon_john = Identicon("john") + identicon_john.render(filename="john", dimensions=150) + + # Assertions + 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") if __name__ == "__main__": unittest.main() \ No newline at end of file