Skip to content

Commit

Permalink
Simplify, make better use of pillow (#193)
Browse files Browse the repository at this point in the history
**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
  • Loading branch information
alcarney authored Feb 24, 2020
1 parent b7002cc commit 3ad6a59
Show file tree
Hide file tree
Showing 9 changed files with 52 additions and 173 deletions.
3 changes: 1 addition & 2 deletions arlunio/__init__.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 0 additions & 18 deletions arlunio/_color.py

This file was deleted.

30 changes: 0 additions & 30 deletions arlunio/_config.py

This file was deleted.

146 changes: 34 additions & 112 deletions arlunio/_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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 = """\
<style>
.arlunio-image {
width: 50%;
margin: auto;
image-rendering: crisp-edges;
image-rendering: pixelated;
border: solid 1px #ddd;
}
</style>
<img class="arlunio-image" src="data:image/png;base64,$data"></img>
"""
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)
6 changes: 4 additions & 2 deletions arlunio/cli/tutorial.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import pkg_resources

import arlunio._config as cfg
import appdirs

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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...")
Expand Down
5 changes: 3 additions & 2 deletions arlunio/doc/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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"))

Expand Down
3 changes: 3 additions & 0 deletions arlunio/lib/__init__.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def readme():
return f.read()


required = ["attrs", "numpy", "Pillow"]
required = ["attrs", "appdirs", "numpy", "Pillow"]
extras = {
"dev": [
"black",
Expand Down
12 changes: 6 additions & 6 deletions tests/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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()

0 comments on commit 3ad6a59

Please sign in to comment.