Skip to content

Commit

Permalink
refactor: to_excel and to_rubiks are now faster
Browse files Browse the repository at this point in the history
  • Loading branch information
Eric-Mendes committed Dec 27, 2023
1 parent ffbbd17 commit cc093ec
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 147 deletions.
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python 3.11.6
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 0 additions & 1 deletion src/unexpected_isaves/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@

3 changes: 3 additions & 0 deletions src/unexpected_isaves/excel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .excel import to_excel

__all__ = [to_excel]
129 changes: 129 additions & 0 deletions src/unexpected_isaves/excel/excel.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/unexpected_isaves/rubiks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .rubiks import to_rubiks

__all__ = ["to_rubiks"]
142 changes: 142 additions & 0 deletions src/unexpected_isaves/rubiks/rubiks.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit cc093ec

Please sign in to comment.