Skip to content

Commit

Permalink
Update arlunio.image module. (#247)
Browse files Browse the repository at this point in the history
- Add `load` and `decode` functions to mirror the existing `save` and `encode`
  functions
- Convert `Image` class from a type alias into a wrapper around a Pillow image
  to provide additional functionality.
- Make `fill` and `colorramp` functions produce RGBA images to make image
  composition easier.
  • Loading branch information
alcarney authored Jun 26, 2020
1 parent 041b46a commit 6f0f580
Show file tree
Hide file tree
Showing 14 changed files with 509 additions and 216 deletions.
10 changes: 5 additions & 5 deletions arlunio/color.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import PIL.ImageColor as Color


def getcolor(color):
def getcolor(*args, **kwargs):
"""Exactly as Pillow's getrgb function."""

return Color.getrgb(color)
return Color.getcolor(*args, **kwargs)


def getcolorf(color):
def getcolorf(*args, **kwargs):
"""Exactly as Pillow's getrgb function, but the values are returned as floats."""

r, g, b = Color.getrgb(color)
return tuple([r / 255, g / 255, b / 255])
col = Color.getcolor(*args, **kwargs)
return tuple([c / 255 for c in col])
298 changes: 233 additions & 65 deletions arlunio/image.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,116 @@
import base64
import enum
import io
import logging
import pathlib
from typing import Optional

import numpy as np
import PIL.Image as PImage
import PIL.ImageColor as PColor

from arlunio.math import lerp
import arlunio.color as color
import arlunio.mask as mask
import arlunio.math as math

logger = logging.getLogger(__name__)

# Create a type alias that we're free to change in the future
Image = PImage.Image

class Image:
"""Our representation of an image, implemented as a wrapper around a standard
Pillow image."""

class Resolutions(enum.Enum):
"""Enum that defines some common image resolutions
def __init__(self, img: PImage.Image):
self.img = img
"""The wrapped pillow image object."""

Members of this enum are tuples containing the width and height which can be
accessed by name::
def __eq__(self, other):

>>> from arlunio.image import Resolutions as R
if not isinstance(other, Image):
return False

>>> hd = R.HD
>>> hd.width
1280
a = np.asarray(self.img)
b = np.asarray(other.img)

>>> hd.height
720
return (a == b).all()

Resolutions can also unpacked::
def __add__(self, other):

>>> width, height = hd
>>> width
1280
if isinstance(other, Image):
other = other.img

>>> height
720
if not isinstance(other, PImage.Image):
raise TypeError("Addition is only supported between images.")

img = self.copy()
img.alpha_composite(other)

return img

@property
def __array_interface__(self):
# Ensure that our version of an image also plays nice with numpy.
return self.img.__array_interface__

def _repr_png_(self):
# Give nice previews in jupyter notebooks
return self.img._repr_png_()

def alpha_composite(self, im, *args, **kwargs):
"""Composites an image onto this image.
See :meth:`pillow:PIL.Image.Image.alpha_composite`
"""

if isinstance(im, Image):
im = im.img

self.img.alpha_composite(im, *args, **kwargs)

def copy(self):
"""Return a copy of the image.
See :meth:`pillow:PIL.Image.Image.copy`
"""
return Image(self.img.copy())

def paste(self, *args, **kwargs):
"""Paste another image into this image.
See :meth:`pillow:PIL.Image.Image.paste`
"""
self.img.paste(*args, **kwargs)

def save(self, *args, **kwargs):
"""Save the image with the given filename.
See :meth:`pillow:PIL.Image.Image.save`
"""
self.img.save(*args, **kwargs)


def new(*args, **kwargs):
"""Creates a new image with the given mode and size
See :func:`pillow:PIL.Image.new`
"""
return Image(PImage.new(*args, **kwargs))

HD = (1280, 720)
"""1280 x 720"""

FHD = (1920, 1080)
"""1920 x 1080"""
def fromarray(*args, **kwargs):
"""Create an image from an array
QHD = (2560, 1440)
"""2560 x 1440"""
See :func:`pillow:PIL.Image.fromarray`
"""
return Image(PImage.fromarray(*args, **kwargs))

def __iter__(self):
value = self.value
return iter([value[0], value[1]])

@property
def width(self):
return self.value[0]
def load(*args, **kwargs) -> Image:
"""Load an image from the given file.
@property
def height(self):
return self.value[1]
See :func:`pillow:PIL.Image.open`
"""
return Image(PImage.open(*args, **kwargs))


def save(image, filename: str, mkdirs: bool = False) -> None:
def save(image: Image, filename: str, mkdirs: bool = False) -> None:
"""Save an image in PNG format.
:param filename: The filepath to save the image to.
Expand All @@ -79,8 +125,24 @@ def save(image, filename: str, mkdirs: bool = False) -> None:
image.save(f)


def encode(image) -> bytes:
"""Return the image encoded as a base64 string."""
def encode(image: Image) -> bytes:
"""Return the image encoded as a base64 string.
Parameters
----------
image:
The image to encode.
Example
-------
::
>>> import arlunio.image as image
>>> img = image.new("RGBA", (8, 8), color='red')
>>> image.encode(img)
b'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFklEQVR4nGP8z8DwnwEPYMInOXwUAAASWwIOH0pJXQAAAABJRU5ErkJggg=='
"""

with io.BytesIO() as byte_stream:
image.save(byte_stream, "PNG")
Expand All @@ -89,66 +151,172 @@ def encode(image) -> bytes:
return base64.b64encode(image_bytes)


def decode(bytestring: bytes) -> Image:
"""Decode the image represented by the given bytestring into an image object.
Parameters
----------
bytestring:
The bytestring to decode.
Example
-------
.. arlunio-image:: Decode Example
:include-code:
::
import arlunio.image as image
bytestring = b'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFklEQVR4nGP8z8DwnwEPYMInOXwUAAASWwIOH0pJXQAAAABJRU5ErkJggg==' # noqa: E501
img = image.decode(bytestring)
"""

data = base64.b64decode(bytestring)
bytes_ = io.BytesIO(data)

return Image(load(bytes_))


def colorramp(values, start: Optional[str] = None, stop: Optional[str] = None) -> Image:
"""Given a range of values, produce an image mapping those values onto colors."""
"""Given a 2d array of values, produce an image gradient based on them.
.. arlunio-image:: Colorramp Demo
:align: right
::
import arlunio.image as image
import arlunio.math as math
import numpy as np
cartesian = math.Cartesian()
p = cartesian(width=256, height=256)
x, y = p[:, :, 0], p[:, :, 1]
values = np.sin(2*x*np.pi) * np.sin(2*y* np.pi)
img = image.colorramp(values)
First this function will scale the input array so that all values fall in the range
:math:`[0, 1]`. It will then produce an image with the same dimensions as the
original array. The color of each pixel will be chosen based on the corresponding
value of the scaled array.
- If the value is :math:`0` the color will be given by the :code:`start` parameter
- If the value is :math:`1` the color will be given by the :code:`stop` parameter
- Otherwise the color will be some mix between the two.
Parameters
----------
values:
The array of values used to decide on the color.
start:
The color to use for values near :math:`0` (default, :code:`black`)
stop:
The color to use for values near :math:`1` (default, :code:`white`)
Examples
--------
.. arlunio-image:: Colorramp Demo 2
:include-code:
::
import arlunio.image as image
import arlunio.math as math
import numpy as np
cartesian = math.Cartesian()
p = cartesian(width=256, height=256)
bg = image.new("RGBA", (256, 256), color="black")
x = image.colorramp(p[:, :, 0], start="#0000", stop="#f007")
y = image.colorramp(p[:, :, 1], start="#0000", stop="#00f7")
img = x + y
"""

# Scale all the values so that they fall into the range [0, 1]
minx = np.min(values)
vs = np.array(values) - minx
vs = vs / np.max(vs)

(r, g, b) = PColor.getrgb("#000") if start is None else PColor.getrgb(start)
(R, G, B) = PColor.getrgb("#fff") if stop is None else PColor.getrgb(stop)
if start is None:
start = "black"

reds = np.floor(lerp(r, R)(vs))
greens = np.floor(lerp(g, G)(vs))
blues = np.floor(lerp(b, B)(vs))
if stop is None:
stop = "white"

pixels = np.array(np.dstack([reds, greens, blues]), dtype=np.uint8)
return PImage.fromarray(pixels)
start = color.getcolor(start, "RGBA")
stop = color.getcolor(stop, "RGBA")

funcs = [math.lerp(a, b) for a, b in zip(start, stop)]
channels = [np.floor(func(vs)) for func in funcs]

pixels = np.array(np.dstack(channels), dtype=np.uint8)
return fromarray(pixels)


def fill(
mask,
color: Optional[str] = None,
mask: mask.Mask,
foreground: Optional[str] = None,
background: Optional[str] = None,
image: Optional[Image] = None,
) -> Image:
"""Given a mask, fill it in with a color.
"""Apply color to an image, as specified by a mask.
Parameters
----------
mask:
The mask used to select the pixels to fill in
color:
The mask that selects the region to be coloured
foreground:
A string representation of the color to use, this can be in any format that is
supported by Pillow's |PIL.ImageColor| module. If omitted this will default to
black.
supported by the :mod:`pillow:PIL.ImageColor` module. If omitted this will
default to black.
background:
In the case where an existing image is not provided this parameter can be used
to set the background color of the generated image. This can be any string that
is accepted by the |PIL.ImageColor| module. If omitted this will default to
white.
is accepted by the :mod:`pillow:PIL.ImageColor` module. If omitted this will
default to transparent
image:
The image to color in, if omitted a new image will be generated.
The image to color in, if omitted a blank image will be used.
Returns
-------
Image
An image with the region selected by the mask colored with the given color
Example
--------
.. arlunio-image:: Fill Demo
:include-code:
::
import arlunio.image as image
import arlunio.shape as shape
circle = shape.Circle(x0=-0.5, y0=0.25, r=0.6)
img = image.fill(circle(width=512, height=256), foreground='red')
circle.x0, circle.y0 = 0, 0
img = image.fill(circle(width=512, height=256), foreground='#0f0', image=img)
circle.x0, circle.y0 = 0.5, -0.25
img = image.fill(circle(width=512, height=256), foreground='blue', image=img)
"""

color = "#000" if color is None else color
fill_color = PColor.getrgb(color)
foreground = "#000" if foreground is None else foreground
fill_color = color.getcolor(foreground, "RGBA")

mask_img = PImage.fromarray(mask)

if image is None:
background = "#fff" if background is None else background
background = "#0000" if background is None else background

height, width = mask.shape
image = PImage.new("RGB", (width, height), color=background)
image = new("RGBA", (width, height), color=background)

else:
image = image.copy()
Expand Down
Loading

0 comments on commit 6f0f580

Please sign in to comment.