From 3ad6a59737fa1595d5faae805da83df23fd43c44 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 24 Feb 2020 20:33:44 +0000 Subject: [PATCH] Simplify, make better use of pillow (#193) **Images** - Use Pillow images throughout rather than our own `Image` abstraction (Closes #192 ) - The `encode` and `save` methods on our old `Image` class are now standalone methods - There's no longer any need for a `arlunio._color` module, we can make use of Pillow's modes **Other** - Use the `appdirs` module to find the location we save the tutorial to, no longer any need for an `arlunuio._config` module - Expose stdlib components at the `arlunio.lib` level --- arlunio/__init__.py | 3 +- arlunio/_color.py | 18 ----- arlunio/_config.py | 30 -------- arlunio/_images.py | 146 +++++++++----------------------------- arlunio/cli/tutorial.py | 6 +- arlunio/doc/directives.py | 5 +- arlunio/lib/__init__.py | 3 + setup.py | 2 +- tests/test_images.py | 12 ++-- 9 files changed, 52 insertions(+), 173 deletions(-) delete mode 100644 arlunio/_color.py delete mode 100644 arlunio/_config.py diff --git a/arlunio/__init__.py b/arlunio/__init__.py index 5f568fbc..fb1f07ac 100644 --- a/arlunio/__init__.py +++ b/arlunio/__init__.py @@ -1,5 +1,4 @@ -from ._color import RGB8 # noqa: F401 from ._core import Collection, Definition, definition # noqa: F401 from ._expressions import all, any, clamp, invert, lerp # noqa: F401 -from ._images import Image, Resolutions, colorramp, fill # noqa: F401 +from ._images import Resolutions, colorramp, encode, fill, save # noqa: F401 from ._version import __version__ # noqa: F401 diff --git a/arlunio/_color.py b/arlunio/_color.py deleted file mode 100644 index b9c0ec4d..00000000 --- a/arlunio/_color.py +++ /dev/null @@ -1,18 +0,0 @@ -import re -import struct - - -class RGB8: - @staticmethod - def parse(color): - """Parse a color as an RGB8 value.""" - short = re.compile(r"\A[#]*[a-fA-F0-9]{3}\Z") - full = re.compile(r"\A[#]*[a-fA-F0-9]{6}\Z") - - if short.fullmatch(color): - color = "".join([c * 2 for c in color.replace("#", "")]) - - if full.fullmatch(color): - return struct.unpack("BBB", bytes.fromhex(color.replace("#", ""))) - - raise ValueError(f"{color} does not represent a valid RGB8 color value.") diff --git a/arlunio/_config.py b/arlunio/_config.py deleted file mode 100644 index 6f96746b..00000000 --- a/arlunio/_config.py +++ /dev/null @@ -1,30 +0,0 @@ -import logging -import os -import sys - -logger = logging.getLogger(__name__) - - -def _find_cache_dir() -> str: - """Try and determine a suitable location to store cached data baed on platform.""" - - if sys.platform == "linux" and "XDG_CACHE_HOME" in os.environ: - return os.environ["XDG_CACHE_HOME"] - - if sys.platform == "linux": - return os.path.join(os.environ["HOME"], ".cache") - - if sys.platform == "windows" and "APPDATA" in os.environ: - return os.environ["APPDATA"] - - # Give up and use the current directory. - return "." - - -def cache_dir() -> str: - cache = os.path.join(_find_cache_dir(), "arlunio") - - if not os.path.isdir(cache): - os.makedirs(cache) - - return cache diff --git a/arlunio/_images.py b/arlunio/_images.py index f65ecba2..8cd5ebb0 100644 --- a/arlunio/_images.py +++ b/arlunio/_images.py @@ -3,12 +3,11 @@ import io import logging import pathlib -import string import numpy as np -import PIL.Image +import PIL.Image as Image +import PIL.ImageColor as Color -from ._color import RGB8 from ._expressions import lerp logger = logging.getLogger(__name__) @@ -61,134 +60,57 @@ def height(self): return self.value[1] -class Image: - """An image is a container for raw pixel data.""" +def save(image, filename: str, mkdirs: bool = False) -> None: + """Save an image in PNG format. - def __init__(self, pixels): - self.pixels = pixels - - def __repr__(self): - y, x, _ = self.pixels.shape - return f"Image<{x} x {y}>" - - def _repr_html_(self): - - data = self.encode().decode("utf-8") - html = """\ - - - """ - template = string.Template(html) - - return template.safe_substitute({"data": data}) - - def __getitem__(self, key): - return Image(self.pixels[key]) - - def __setitem__(self, key, value): - self.pixels[key] = value - - @classmethod - def new(cls, width: int, height: int, background: str = None, colorspace=None): - """Create a new Image with the given width and height. - - This creates an "empty" image of a given width and height with a solid - background color. This color can be set using the :code:`background` color - argument, or if :code:`None` then the background will default to white. - - The :code:`background` argument should be in the form of a string - representing the color as an RGB hex code (like those used in web design - e.g. :code:`#ffbb00`) - - The :code:`colorspace` parameter can be used to change the colorspace used when - drawing the image. By default this is the :code:`RGB8` colorspace. - - :param width: The width of the image in pixels - :param height: The height of the image in pixels - :param background: The background color to use. - :param colorspace: The colorspace to use. - - """ - - if background is None: - background = "ffffff" - - if colorspace is None: - colorspace = RGB8 - - bg_color = colorspace.parse(background) + :param filename: The filepath to save the image to. + :param mkdirs: If true, make any parent directories + """ + path = pathlib.Path(filename) - pixels = np.full((height, width, 3), bg_color, dtype=np.uint8) - return cls(pixels) + if not path.parent.exists() and mkdirs: + path.parent.mkdir(parents=True) - def _as_pillow_image(self): - height, width, _ = self.pixels.shape + with open(filename, "wb") as f: + image.save(f) - return PIL.Image.frombuffer( - "RGB", (width, height), self.pixels, "raw", "RGB", 0, 1 - ) - def save(self, filename: str, mkdirs: bool = False) -> None: - """Save an image in PNG format. +def encode(image) -> bytes: + """Return the image encoded as a base64 string.""" - :param filename: The filepath to save the image to. - :param mkdirs: If true, make any parent directories - """ - path = pathlib.Path(filename) + with io.BytesIO() as byte_stream: + image.save(byte_stream, "PNG") + image_bytes = byte_stream.getvalue() - if not path.parent.exists() and mkdirs: - path.parent.mkdir(parents=True) + return base64.b64encode(image_bytes) - image = self._as_pillow_image() - with open(filename, "wb") as f: - image.save(f) +def colorramp(values, start=None, stop=None): + """Given a range of values, produce an image mapping those values onto colors.""" - def encode(self) -> bytes: - """Return the image encoded as a base64 string.""" - image = self._as_pillow_image() + (r, g, b) = Color.getrgb("#000") if start is None else Color.getrgb(start) + (R, G, B) = Color.getrgb("#fff") if stop is None else Color.getrgb(stop) - with io.BytesIO() as byte_stream: - image.save(byte_stream, "PNG") - image_bytes = byte_stream.getvalue() + reds = np.floor(lerp(r, R)(values)) + greens = np.floor(lerp(g, G)(values)) + blues = np.floor(lerp(b, B)(values)) - return base64.b64encode(image_bytes) + pixels = np.array(np.dstack([reds, greens, blues]), dtype=np.uint8) + return Image.fromarray(pixels) -def fill(mask, color=None, background=None) -> Image: +def fill(mask, color=None, background=None): """Given a mask, fill it in with a color.""" - if isinstance(color, str): - color = RGB8.parse(color) + color = "#000" if color is None else color + background = "#fff" if background is None else background - if color is None: - color = RGB8.parse("#000") + mask_img = Image.fromarray(mask) + fill_color = Color.getrgb(color) height, width = mask.shape - image = Image.new(width, height, background=background) - image[mask] = color + image = Image.new("RGB", (width, height), color=background) + image.paste(fill_color, mask=mask_img) return image - - -def colorramp(values, start=None, stop=None) -> Image: - """Given a range of values, produce an image mapping those values onto colors.""" - - (r, g, b) = RGB8.parse("000") if start is None else RGB8.parse(start) - (R, G, B) = RGB8.parse("fff") if stop is None else RGB8.parse(stop) - - reds = np.floor(lerp(r, R)(values)) - greens = np.floor(lerp(g, G)(values)) - blues = np.floor(lerp(b, B)(values)) - - pixels = np.array(np.dstack([reds, greens, blues]), dtype=np.uint8) - return Image(pixels) diff --git a/arlunio/cli/tutorial.py b/arlunio/cli/tutorial.py index 49726a15..079c589c 100644 --- a/arlunio/cli/tutorial.py +++ b/arlunio/cli/tutorial.py @@ -5,7 +5,7 @@ import pkg_resources -import arlunio._config as cfg +import appdirs logger = logging.getLogger(__name__) @@ -49,7 +49,9 @@ def exclude_item(item, path): shutil.copy(path, dest) def run(self, reset: bool = False): - tutorial_dir = os.path.join(cfg.cache_dir(), "tutorial") + tutorial_dir = os.path.join( + appdirs.user_data_dir(appname="arlunio", appauthor="swyddfa"), "tutorial" + ) if reset and os.path.exists(tutorial_dir): logger.info("Existing tutorial found found, resetting...") diff --git a/arlunio/doc/directives.py b/arlunio/doc/directives.py index 541947e1..680d3375 100644 --- a/arlunio/doc/directives.py +++ b/arlunio/doc/directives.py @@ -11,6 +11,7 @@ from docutils import nodes from docutils.parsers import rst from docutils.statemachine import StringList +from PIL.Image import Image from sphinx.util import logging, nested_parse_with_titles logger = logging.getLogger(__name__) @@ -243,11 +244,11 @@ def render_image(src: str) -> List[nodes.Node]: image = None for obj in environment.values(): - if isinstance(obj, ar.Image): + if isinstance(obj, Image): image = obj if image is not None: - context = {"data": image.encode().decode("utf-8")} + context = {"data": ar.encode(image).decode("utf-8")} html = IMAGE_TEMPLATE.safe_substitute(context) doctree.append(nodes.raw("", html, format="html")) diff --git a/arlunio/lib/__init__.py b/arlunio/lib/__init__.py index e69de29b..a9526a9a 100644 --- a/arlunio/lib/__init__.py +++ b/arlunio/lib/__init__.py @@ -0,0 +1,3 @@ +from .parameters import R, T, X, Y # noqa: F401 +from .pattern import Grid, Map, Pixelize # noqa: F401 +from .shapes import Circle, Ellipse, Rectangle, Square, SuperEllipse # noqa: F401 diff --git a/setup.py b/setup.py index 375b0506..2de84be7 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ def readme(): return f.read() -required = ["attrs", "numpy", "Pillow"] +required = ["attrs", "appdirs", "numpy", "Pillow"] extras = { "dev": [ "black", diff --git a/tests/test_images.py b/tests/test_images.py index 32761ef2..0b70ec9e 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -13,20 +13,20 @@ def test_colorramp_defaults(): dtype=np.uint8, ) - assert (img.pixels == pix).all() + assert (np.asarray(img) == pix).all() def test_colorramp(): """Ensure that the colorramp parses the colors it is given.""" values = np.array([[0.0, 0.5], [0.75, 1.0]]) - img = ar.colorramp(values, start="f00", stop="0f0") + img = ar.colorramp(values, start="#f00", stop="#0f0") pix = np.array( [[[255, 0, 0], [127, 127, 0]], [[63, 191, 0], [0, 255, 0]]], dtype=np.uint8 ) - assert (img.pixels == pix).all() + assert (np.asarray(img) == pix).all() def test_fill_defaults(): @@ -39,17 +39,17 @@ def test_fill_defaults(): [[[255, 255, 255], [0, 0, 0]], [[0, 0, 0], [255, 255, 255]]], dtype=np.uint8 ) - assert (img.pixels == pix).all() + assert (np.asarray(img) == pix).all() def test_fill(): """Ensure that the fill method parses colors that it is given.""" mask = np.array([[False, True], [True, False]]) - img = ar.fill(mask, color="f00", background="0f0") + img = ar.fill(mask, color="#f00", background="#0f0") pix = np.array( [[[0, 255, 0], [255, 0, 0]], [[255, 0, 0], [0, 255, 0]]], dtype=np.uint8 ) - assert (img.pixels == pix).all() + assert (np.asarray(img) == pix).all()