From cc093ec08fc82d530dfe7f1e99fb97ae56ff614c Mon Sep 17 00:00:00 2001 From: Eric-Mendes Date: Wed, 27 Dec 2023 00:00:23 -0300 Subject: [PATCH] refactor: to_excel and to_rubiks are now faster --- .tool-versions | 1 + CHANGELOG.md | 4 + pyproject.toml | 2 +- src/unexpected_isaves/__init__.py | 1 - src/unexpected_isaves/excel/__init__.py | 3 + src/unexpected_isaves/excel/excel.py | 129 ++++++++++++++++++ src/unexpected_isaves/rubiks/__init__.py | 3 + src/unexpected_isaves/rubiks/rubiks.py | 142 ++++++++++++++++++++ src/unexpected_isaves/save_image.py | 160 +++-------------------- 9 files changed, 298 insertions(+), 147 deletions(-) create mode 100644 .tool-versions create mode 100644 src/unexpected_isaves/excel/__init__.py create mode 100644 src/unexpected_isaves/excel/excel.py create mode 100644 src/unexpected_isaves/rubiks/__init__.py create mode 100644 src/unexpected_isaves/rubiks/rubiks.py diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..074b7ca --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.11.6 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 291d658..c5cfd01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.0] - 2023-12-27 +### Refactored +- `to_excel` and `to_rubiks` were refactored amounting in a 1.31x and a 1.37x speedup, respectively. + ## [2.1.4] - 2023-10-25 ### Added - Now we support Minecraft versions `1.20.1` and `1.20.2` diff --git a/pyproject.toml b/pyproject.toml index 7f67b41..45f8da8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "unexpected_isaves" -version = "2.1.4" +version = "2.2.0" description = "A Python library that paints an image on a spreadsheet, builds its pixel art in Minecraft, makes its ascii art, or makes a rubik's cube art out of it." readme = "README.md" requires-python = ">=3.8" diff --git a/src/unexpected_isaves/__init__.py b/src/unexpected_isaves/__init__.py index 8b13789..e69de29 100644 --- a/src/unexpected_isaves/__init__.py +++ b/src/unexpected_isaves/__init__.py @@ -1 +0,0 @@ - diff --git a/src/unexpected_isaves/excel/__init__.py b/src/unexpected_isaves/excel/__init__.py new file mode 100644 index 0000000..808021e --- /dev/null +++ b/src/unexpected_isaves/excel/__init__.py @@ -0,0 +1,3 @@ +from .excel import to_excel + +__all__ = [to_excel] diff --git a/src/unexpected_isaves/excel/excel.py b/src/unexpected_isaves/excel/excel.py new file mode 100644 index 0000000..d3ea70f --- /dev/null +++ b/src/unexpected_isaves/excel/excel.py @@ -0,0 +1,129 @@ +import os +from typing import List, Tuple, Union + +import numpy as np +from openpyxl import Workbook, styles, utils +from PIL import Image + + +def _save( + processed_pil_image: List[List[str]], + path: Union[os.PathLike, str], + image_position: Tuple[int, int], + **spreadsheet_kwargs, +) -> None: + starting_row, starting_col = image_position + ending_row, ending_col = ( + starting_row + len(processed_pil_image), + starting_col + len(processed_pil_image[0]), + ) + + wb = Workbook() + ws = wb.active + + image_name = os.path.splitext(os.path.split(path)[1])[0] + ws.title = image_name + + for row in range(starting_row, ending_row + 1): + for col in range(starting_col, ending_col + 1): + cell = ws.cell(row=row, column=col) + cell.value = processed_pil_image[row - starting_row - 1][ + col - starting_col - 1 + ] + + # Makes cells squared + ws.row_dimensions[row].height = spreadsheet_kwargs.get("row_height", 15) + ws.column_dimensions[ + utils.get_column_letter(col) + ].width = spreadsheet_kwargs.get("column_width", 2.3) + + # Painting the cell + cell.fill = styles.PatternFill( + start_color=cell.value, end_color=cell.value, fill_type="solid" + ) + if spreadsheet_kwargs.get("delete_cell_value", True): + cell.value = None # Deletes the text from the cell + + # Saves spreadsheet already zoomed in or out + ws.sheet_view.zoomScale = spreadsheet_kwargs.get("zoom_scale", 20) + wb.save(path) + return None + + +def _load_image(image: Union[Image.Image, os.PathLike, str]) -> Image.Image: + if isinstance(image, (os.PathLike, str)): + if not os.path.exists(image): + raise ValueError("Error loading image. Image path not found.") + image = Image.open(image) + return image + + +def _to_openpyxl_colors(image: Image.Image) -> List[List[str]]: + image_colors_processed = [ + ["%02x%02x%02x" % tuple(item) for item in row] + for row in np.array(image).tolist() + ] + return image_colors_processed + + +def _process(image: Image.Image, lower_image_size_by: int): + image_rgb = image.convert("RGB") + image_rgb_resized = image_rgb.resize( + ( + image_rgb.size[0] // lower_image_size_by, + image_rgb.size[1] // lower_image_size_by, + ) + ) + image_openpyxl_colors_resized = _to_openpyxl_colors(image_rgb_resized) + + return image_openpyxl_colors_resized + + +def to_excel( + image: Union[Image.Image, os.PathLike, str], + path: Union[os.PathLike, str], + lower_image_size_by: int = 10, + image_position: Tuple[int, int] = (0, 0), + **spreadsheet_kwargs, +) -> None: + """ + - Coded originally on https://github.com/Eric-Mendes/image2excel + + Saves an image as a `.xlsx` file by coloring its cells each pixel's color. + + Args + image: Your image opened using the `PIL.Image` module or the image's path. + path: The path that you want to save your output file. Example: `/home/user/Documents/my_image.xlsx`. + lower_image_size_by: A factor that the function will divide your image's dimensions by. Defaults to `10`. It is very important that you lower your image's dimensions because a big image might take the function a long time to process plus your spreadsheet will probably take a long time to load on any software that you use to open it. + image_position: a tuple determining the position of the top leftmost pixel. Cannot have negative values. Defaults to `(0,0)`. + **spreadsheet_kwargs: Optional parameters to tweak the spreadsheet's appearance. The default values on `row_height` and `column_width` were specifically thought out so that they make the cells squared, however - as any hardcoded value - they might not do the trick on your device. That is when you might want to tweak them a little bit. + row_height (`float`): the rows' height. Defaults to `15`. + column_width (`float`): the columns' width. Defaults to `2.3`. + delete_cell_value (`bool`): wheter to keep or not the text corresponding to that color. Defaults to `True`. + zoom_scale (`int`): how much to zoom in or out on the spreadsheet. Defaults to `20` which seems to be the default max zoom out on most spreadsheet softwares. + + Returns + `None`, but outputs a `.xlsx` file on the given `path`. + """ + if os.path.exists(path): + raise ValueError( + f"{path} already exists. Please provide a new path for your .xlsx." + ) + + if image_position[0] < 0 or image_position[1] < 0: + raise ValueError("image_position cannot have negative values.") + + pil_image = _load_image(image) + processed_pil_image = _process(pil_image, lower_image_size_by) + image_position_processed = ( + image_position[0] + int(image_position[0] == 0), + image_position[1] + int(image_position[1] == 0), + ) + save_result = _save( + processed_pil_image, + path=path, + image_position=image_position_processed, + **spreadsheet_kwargs, + ) + + return save_result diff --git a/src/unexpected_isaves/rubiks/__init__.py b/src/unexpected_isaves/rubiks/__init__.py new file mode 100644 index 0000000..84bd581 --- /dev/null +++ b/src/unexpected_isaves/rubiks/__init__.py @@ -0,0 +1,3 @@ +from .rubiks import to_rubiks + +__all__ = ["to_rubiks"] diff --git a/src/unexpected_isaves/rubiks/rubiks.py b/src/unexpected_isaves/rubiks/rubiks.py new file mode 100644 index 0000000..bcb1b66 --- /dev/null +++ b/src/unexpected_isaves/rubiks/rubiks.py @@ -0,0 +1,142 @@ +import os +from math import sqrt +from typing import List, Tuple, Union + +import numpy as np +from openpyxl import Workbook, styles, utils +from PIL import Image + + +def _save( + processed_pil_image: List[List[str]], + path: Union[os.PathLike, str], + **spreadsheet_kwargs, +) -> int: + wb = Workbook() + ws = wb.active + + image_name = os.path.splitext(os.path.split(path)[1])[0] + ws.title = image_name + + for row in range(1, len(processed_pil_image) + 1): + for col in range(1, len(processed_pil_image[0]) + 1): + cell = ws.cell(row=row, column=col) + cell.value = processed_pil_image[row - 1][col - 1] + + # Makes cells squared + ws.row_dimensions[row].height = spreadsheet_kwargs.get("row_height", 15) + ws.column_dimensions[ + utils.get_column_letter(col) + ].width = spreadsheet_kwargs.get("column_width", 2.3) + + # Painting the cell + cell.fill = styles.PatternFill( + start_color=cell.value, end_color=cell.value, fill_type="solid" + ) + if spreadsheet_kwargs.get("delete_cell_value", True): + cell.value = None # Deletes the text from the cell + + # Saves spreadsheet already zoomed in or out + ws.sheet_view.zoomScale = spreadsheet_kwargs.get("zoom_scale", 20) + wb.save(path) + + return len(processed_pil_image) // 3 * len(processed_pil_image[0]) // 3 + + +def _load_image(image: Union[Image.Image, os.PathLike, str]) -> Image.Image: + if isinstance(image, (os.PathLike, str)): + if not os.path.exists(image): + raise ValueError("Error loading image. Image path not found.") + image = Image.open(image) + return image + + +def _map_to_rubiks_palette(color: Tuple[int, int, int]) -> Tuple[int, int, int]: + palette = [ + (255, 0, 0), # red + (0, 255, 0), # green + (0, 0, 255), # blue + (255, 255, 0), # yellow + (255, 255, 255), # white + (255, 128, 0), # orange + ] + min_dist = None + mapped_color = None + + for clr in palette: + euclidean_distance = sqrt(sum([pow(p - c, 2) for p, c in zip(clr, color)])) + + if min_dist is None or euclidean_distance < min_dist: + min_dist = euclidean_distance + mapped_color = clr + + return mapped_color + + +def _to_openpyxl_colors(image: Image.Image) -> List[List[str]]: + image_colors_mapped = [ + [_map_to_rubiks_palette(color) for color in row] + for row in np.array(image).tolist() + ] + image_colors_processed = [ + ["%02x%02x%02x" % tuple(item) for item in row] for row in image_colors_mapped + ] + return image_colors_processed + + +def _process(image: Image.Image, lower_image_size_by: int): + image_rgb = image.convert("RGB") + image_rgb_resized = image_rgb.resize( + ( + image_rgb.size[0] // lower_image_size_by, + image_rgb.size[1] // lower_image_size_by, + ) + ) + image_rgb_resized_in_rubiks = image_rgb_resized.resize( + ( + int(round(image_rgb_resized.size[0] / 3)) * 3, + int(round(image_rgb_resized.size[1] / 3)) * 3, + ) + ) + image_openpyxl_colors_resized = _to_openpyxl_colors(image_rgb_resized_in_rubiks) + + return image_openpyxl_colors_resized + + +def to_rubiks( + image: Union[Image.Image, os.PathLike], + path: Union[os.PathLike, str], + lower_image_size_by: int = 10, + **spreadsheet_kwargs, +) -> None: + """ + Saves an image as a `.xlsx` file by mapping its colors to the closest of the standard colors of a rubik's cube, then coloring its cells accordingly. + + Args + image: Your image opened using the `PIL.Image` module or the image's path. + path: The path that you want to save your output file. Example: `/home/user/Documents/my_image.xlsx`. + lower_image_size_by: A factor that the function will divide your image's dimensions by. Defaults to `10`. It is very important that you lower your image's dimensions because a big image might take the function a long time to process plus your spreadsheet will probably take a long time to load on any software that you use to open it. + image_position: a tuple determining the position of the top leftmost pixel. Cannot have negative values. Defaults to `(0,0)`. + **spreadsheet_kwargs: Optional parameters to tweak the spreadsheet's appearance. The default values on `row_height` and `column_width` were specifically thought out so that they make the cells squared, however - as any hardcoded value - they might not do the trick on your device. That is when you might want to tweak them a little bit. + row_height (`float`): the rows' height. Defaults to `15`. + column_width (`float`): the columns' width. Defaults to `2.3`. + delete_cell_value (`bool`): wheter to keep or not the text corresponding to that color. Defaults to `True`. + zoom_scale (`int`): how much to zoom in or out on the spreadsheet. Defaults to `20` which seems to be the default max zoom out on most spreadsheet softwares. + + Returns + An integer representing how many rubik's cubes are needed to make the generated image. + """ + if os.path.exists(path): + raise ValueError( + f"{path} already exists. Please provide a new path for your .xlsx." + ) + + pil_image = _load_image(image) + processed_pil_image = _process(pil_image, lower_image_size_by) + save_result = _save( + processed_pil_image, + path=path, + **spreadsheet_kwargs, + ) + + return save_result diff --git a/src/unexpected_isaves/save_image.py b/src/unexpected_isaves/save_image.py index 3aca5c5..fbf6c23 100644 --- a/src/unexpected_isaves/save_image.py +++ b/src/unexpected_isaves/save_image.py @@ -2,29 +2,29 @@ import os from contextlib import suppress from math import sqrt -from typing import Optional, Tuple, Union, List +from typing import List, Optional, Tuple, Union import numpy as np import pandas as pd -from openpyxl import load_workbook, styles, utils from PIL import Image +from . import excel, rubiks + def to_excel( - image: Union[Image.Image, str], - path: str, + image: Union[Image.Image, str, os.PathLike], + path: Union[str, os.PathLike], lower_image_size_by: int = 10, image_position: Tuple[int, int] = (0, 0), **spreadsheet_kwargs, ) -> None: """ - - Added on release 0.0.1; - Coded originally on https://github.com/Eric-Mendes/image2excel Saves an image as a `.xlsx` file by coloring its cells each pixel's color. Args - image: Your image opened using the `PIL.Image` module or the image's path as `str`. + image: Your image opened using the `PIL.Image` module or the image's path. path: The path that you want to save your output file. Example: `/home/user/Documents/my_image.xlsx`. lower_image_size_by: A factor that the function will divide your image's dimensions by. Defaults to `10`. It is very important that you lower your image's dimensions because a big image might take the function a long time to process plus your spreadsheet will probably take a long time to load on any software that you use to open it. image_position: a tuple determining the position of the top leftmost pixel. Cannot have negative values. Defaults to `(0,0)`. @@ -37,70 +37,14 @@ def to_excel( Returns `None`, but outputs a `.xlsx` file on the given `path`. """ - if isinstance(image, str): - image = Image.open(image) - - image_position_row = image_position[0] - image_position_col = image_position[1] - if image_position_row > 0: - image_position_row -= 1 - if image_position_col > 0: - image_position_col -= 1 - if image_position_row < 0 or image_position_col < 0: - raise ValueError("image_position cannot have negative values.") - - image = image.convert("RGB") - image = image.resize( - (image.size[0] // lower_image_size_by, image.size[1] // lower_image_size_by) - ) - # OpenPyxl colors work in a weird way - image_colors_processed = [ - ["%02x%02x%02x" % tuple(item) for item in row] - for row in np.array(image).tolist() - ] - - df = pd.DataFrame(image_colors_processed) - image_name = os.path.splitext(os.path.split(path)[1])[0] - - # Saving a DataFrame where each cell has a text corresponding to the RGB color its background should be - df.to_excel( + excel.to_excel( + image, path, - index=False, - header=False, - startrow=image_position_row, - startcol=image_position_col, + lower_image_size_by, + image_position, + **spreadsheet_kwargs, ) - # Loading the excel file, painting each cell with its color and saving the updates - wb = load_workbook(path) - - ws = wb.active - ws.title = image_name - - for row in range(1, df.shape[0] + 1): - for col in range(1, df.shape[1] + 1): - cell = ws.cell( - row=row + image_position_row, column=col + image_position_col - ) - # Makes cells squared - ws.row_dimensions[row + image_position_row].height = spreadsheet_kwargs.get( - "row_height", 15 - ) - ws.column_dimensions[ - utils.get_column_letter(col + image_position_col) - ].width = spreadsheet_kwargs.get("column_width", 2.3) - - # Painting the cell - cell.fill = styles.PatternFill( - start_color=cell.value, end_color=cell.value, fill_type="solid" - ) - if spreadsheet_kwargs.get("delete_cell_value", True): - cell.value = None # Deletes the text from the cell - - # Saves spreadsheet already zoomed in or out - ws.sheet_view.zoomScale = spreadsheet_kwargs.get("zoom_scale", 20) - wb.save(path) - def __to_minecraft_save( res: List[str], @@ -393,23 +337,9 @@ def getAverageL(image): return "\n".join(aimg) -def __map_to_palette(color, palette): - min_dist = None - mapped_color = None - - for clr in palette: - euclidean_distance = sqrt(sum([pow(p - c, 2) for p, c in zip(clr, color)])) - - if min_dist is None or euclidean_distance < min_dist: - min_dist = euclidean_distance - mapped_color = clr - - return mapped_color - - def to_rubiks( - image: Union[Image.Image, str], - path: str, + image: Union[Image.Image, str, os.PathLike], + path: Union[str, os.PathLike], lower_image_size_by: int = 10, **spreadsheet_kwargs, ) -> int: @@ -417,7 +347,7 @@ def to_rubiks( Saves an image as a `.xlsx` file by mapping its colors to the closest of the standard colors of a rubik's cube, then coloring its cells accordingly. Args - image: Your image opened using the `PIL.Image` module or the image's path as `str`. + image: Your image opened using the `PIL.Image` module or the image's path. path: The path that you want to save your output file. Example: `/home/user/Documents/my_image.xlsx`. lower_image_size_by: A factor that the function will divide your image's dimensions by. Defaults to `10`. It is very important that you lower your image's dimensions because a big image might take the function a long time to process plus your spreadsheet will probably take a long time to load on any software that you use to open it. image_position: a tuple determining the position of the top leftmost pixel. Cannot have negative values. Defaults to `(0,0)`. @@ -430,64 +360,4 @@ def to_rubiks( Returns An integer representing how many rubik's cubes are needed to make the generated image. """ - if isinstance(image, str): - image = Image.open(image) - - image = image.convert("RGB") - image = image.resize( - (image.size[0] // lower_image_size_by, image.size[1] // lower_image_size_by) - ) - image = image.resize( - (int(round(image.size[0] / 3)) * 3, int(round(image.size[1] / 3)) * 3) - ) - rubiks_palette = [ - (255, 0, 0), # red - (0, 255, 0), # green - (0, 0, 255), # blue - (255, 255, 0), # yellow - (255, 255, 255), # white - (255, 128, 0), # orange - ] - - df = pd.DataFrame(np.array(image).tolist()).applymap( - lambda color: "%02x%02x%02x" - % tuple(__map_to_palette(color, palette=rubiks_palette)) - ) - - image_name = os.path.splitext(os.path.split(path)[1])[0] - - # Saving a DataFrame where each cell has a text corresponding to the RGB color its background should be - df.to_excel( - path, - index=False, - header=False, - ) - - # Loading the excel file, painting each cell with its color and saving the updates - wb = load_workbook(path) - - ws = wb.active - ws.title = image_name - - for row in range(1, df.shape[0] + 1): - for col in range(1, df.shape[1] + 1): - cell = ws.cell(row=row, column=col) - - # Makes cells squared - ws.row_dimensions[row].height = spreadsheet_kwargs.get("row_height", 15) - ws.column_dimensions[ - utils.get_column_letter(col) - ].width = spreadsheet_kwargs.get("column_width", 2.3) - - # Painting the cell - cell.fill = styles.PatternFill( - start_color=cell.value, end_color=cell.value, fill_type="solid" - ) - if spreadsheet_kwargs.get("delete_cell_value", True): - cell.value = None # Deletes the text from the cell - - # Saves spreadsheet already zoomed in or out - ws.sheet_view.zoomScale = spreadsheet_kwargs.get("zoom_scale", 20) - wb.save(path) - - return image.size[0] // 3 * image.size[1] // 3 + return rubiks.to_rubiks(image, path, lower_image_size_by, **spreadsheet_kwargs)