From 6f0f5808c440b8b6fdbc45b448350eb5bff6dad6 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 26 Jun 2020 15:49:36 +0100 Subject: [PATCH] Update `arlunio.image` module. (#247) - 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. --- arlunio/color.py | 10 +- arlunio/image.py | 298 +++++++++++++++++++++++++++++-------- arlunio/mask.py | 54 ++++--- arlunio/math.py | 43 ++++-- arlunio/pattern.py | 6 +- arlunio/raytrace/camera.py | 2 +- arlunio/shape.py | 17 ++- arlunio/testing.py | 16 +- changes/247.stdlib.rst | 8 + docs/_definitions.rst | 34 ----- docs/conf.py | 9 +- docs/stdlib/image.rst | 36 ++++- pytest.ini | 2 +- tests/test_image.py | 190 +++++++++++++++++------ 14 files changed, 509 insertions(+), 216 deletions(-) create mode 100644 changes/247.stdlib.rst delete mode 100644 docs/_definitions.rst diff --git a/arlunio/color.py b/arlunio/color.py index 26566915..2eece138 100644 --- a/arlunio/color.py +++ b/arlunio/color.py @@ -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]) diff --git a/arlunio/image.py b/arlunio/image.py index b140894a..28576987 100644 --- a/arlunio/image.py +++ b/arlunio/image.py @@ -1,5 +1,4 @@ import base64 -import enum import io import logging import pathlib @@ -7,64 +6,111 @@ 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. @@ -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") @@ -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() diff --git a/arlunio/mask.py b/arlunio/mask.py index f1047fa7..dadbca32 100644 --- a/arlunio/mask.py +++ b/arlunio/mask.py @@ -172,14 +172,14 @@ def MaskMul( return a(width=width, height=height) * b(width=width, height=height) -def any_(*args: Union[bool, np.ndarray]) -> Mask: +def any_(*args: Union[bool, np.ndarray, Mask]) -> Mask: """Given a number of conditions, return :code:`True` if any of the conditions are true. This function is implemented as a thin wrapper around numpy's - :code:`np.logical_or` function so that it can take an arbitrary number of inputs. - This also means that this function will accept arrays of differing sizes - assuming - that they can be broadcasted to a common shape. + :data:`numpy:numpy.logical_or` function so that it can take an arbitrary number of + inputs. This also means that this function will accept arrays of differing sizes, + assuming that they can be broadcasted to a common shape. Parameters ---------- @@ -190,10 +190,10 @@ def any_(*args: Union[bool, np.ndarray]) -> Mask: Examples -------- - >>> from arlunio.mask import any_ - >>> any_(True, False, False) + >>> import arlunio.mask as mask + >>> mask.any_(True, False, False) Mask(True) - >>> any_(False, False, False, False) + >>> mask.any_(False, False, False, False) Mask(False) If the arguments are boolean numpy arrays, then the any condition is applied @@ -203,13 +203,12 @@ def any_(*args: Union[bool, np.ndarray]) -> Mask: >>> x1 = np.array([True, False, True]) >>> x2 = np.array([False, False, True]) >>> x3 = np.array([False, True, False]) - >>> any_(x1, x2, x3) + >>> mask.any_(x1, x2, x3) Mask([ True, True, True]) - This function can even handle a mixture of arrays and single values - assuming - their shapes can be broadcasted to a common shape. + The arguments can be any mixture of booleans, arrays and masks. - >>> any_(False, np.array([True, False]), np.array([[False, True], [True, False]])) + >>> mask.any_(False, Mask([True, False]), np.array([[False, True], [True, False]])) Mask([[ True, True], [ True, False]]) @@ -217,26 +216,26 @@ def any_(*args: Union[bool, np.ndarray]) -> Mask: See Also -------- - |numpy.Broadcasting| + :doc:`numpy:user/basics.broadcasting` Numpy documentation on broadcasting. - |numpy.Array Broadcasting| + :doc:`numpy:user/theory.broadcasting` Further background on broadcasting. - |numpy.logical_or| - Reference documentation on the :code:`np.logical_or` function + :data:`numpy:numpy.logical_or` + Reference documentation on the :code:`numpy.logical_or` function """ return Mask(functools.reduce(np.logical_or, args)) -def all_(*args: Union[bool, np.ndarray]) -> Mask: +def all_(*args: Union[bool, np.ndarray, Mask]) -> Mask: """Given a number of conditions, return :code:`True` only if **all** of the given conditions are true. This function is implemented as a thin wrapper around numpy's - :code:`np.logical_and` function so that it can take an arbitrary number of inputs. - This also means that this function will accept arrays of differing sizes - assuming - that they can be broadcasted to a common shape. + :data:`numpy:numpy.logical_and` function so that it can take an arbitrary number of + inputs. This also means that this function will accept arrays of differing sizes, + assuming that they can be broadcasted to a common shape. Parameters ---------- @@ -247,10 +246,10 @@ def all_(*args: Union[bool, np.ndarray]) -> Mask: Examples -------- - >>> from arlunio.mask import all_ - >>> all_(True, True, True) + >>> import arlunio.mask as mask + >>> mask.all_(True, True, True) Mask(True) - >>> all_(True, False, True, True) + >>> mask.all_(True, False, True, True) Mask(False) If the arguments are boolean numpy arrays, then the any condition is applied @@ -263,10 +262,9 @@ def all_(*args: Union[bool, np.ndarray]) -> Mask: >>> all_(x1, x2, x3) Mask([False, False, True]) - This function can even handle a mixture of arrays and single values - assuming - their shapes can be broadcasted to a common shape. + Arugments can be any mixture of booleans, masks and numpy arrays. - >>> all_(True, np.array([True, False]), np.array([[False, True], [True, False]])) + >>> mask.all_(True, Mask([True, False]), np.array([[False, True], [True, False]])) Mask([[False, False], [ True, False]]) @@ -274,13 +272,13 @@ def all_(*args: Union[bool, np.ndarray]) -> Mask: See Also -------- - |numpy.Broadcasting| + :doc:`numpy:user/basics.broadcasting` Numpy documentation on broadcasting. - |numpy.Array Broadcasting| + :doc:`numpy:user/theory.broadcasting` Further background on broadcasting. - |numpy.logical_and| + :data:`numpy:numpy.logical_and` Reference documentation on the :code:`logical_and` function. """ return Mask(functools.reduce(np.logical_and, args)) diff --git a/arlunio/math.py b/arlunio/math.py index 0fb413e7..de5c8c08 100644 --- a/arlunio/math.py +++ b/arlunio/math.py @@ -106,11 +106,12 @@ def normalise(vs): @ar.definition def X(width: int, height: int, *, x0=0, scale=1, stretch=False): - """ + """Cartesian :math:`x` coordinates. + .. arlunio-image:: X Coordinates :align: right - Cartesian X coordinates:: + :: from arlunio.math import X from arlunio.image import colorramp @@ -182,11 +183,12 @@ def X(width: int, height: int, *, x0=0, scale=1, stretch=False): @ar.definition def Y(width: int, height: int, *, y0=0, scale=1, stretch=False): - """ + """Cartesian :math:`y` coordinates + .. arlunio-image:: Y Coordinates :align: right - Cartesian Y coordinates:: + :: from arlunio.math import Y from arlunio.image import colorramp @@ -262,11 +264,12 @@ def Y(width: int, height: int, *, y0=0, scale=1, stretch=False): @ar.definition def R(x: X, y: Y): - """ + """Polar :math:`r` coordinates. + .. arlunio-image:: R Coordinates :align: right - Polar R coordinates:: + :: from arlunio.math import R from arlunio.image import colorramp @@ -295,8 +298,8 @@ def R(x: X, y: Y): [1.41421356, 1.11803399, 1. , 1.11803399, 1.41421356]]) While this definition does not currently have any attributes of its own, since it's - derived from the |X| and |Y| definitions it automatically inherits the attributes - from these base definitions:: + derived from the :class:`arlunio.math.X` and :class:`arlunio.math.Y` definitions it + automatically inherits the attributes from these base definitions:: >>> r = R(x0=-2, y0=-2, scale=2) >>> r(width=5, height=5) @@ -316,11 +319,12 @@ def R(x: X, y: Y): @ar.definition def T(x: X, y: Y, *, t0=0): - """ + """Polar :math:`\\theta` coordinates. + .. arlunio-image:: T Coordinates :align: right - Polar T coordinates:: + :: from arlunio.math import T from arlunio.image import colorramp @@ -366,8 +370,9 @@ def T(x: X, y: Y, *, t0=0): [-5.8195377 , -5.49778714, -4.71238898, -3.92699082, -3.60524026], [-5.49778714, -5.17603659, -4.71238898, -4.24874137, -3.92699082]]) - Also, being a definition derived from |X| and |Y| the attributes for these - definitions are also available to control the output:: + Also, being a definition derived from :class:`arlunio.math.X` and + :class:`arlunio.math.Y` the attributes for these definitions are also available to + control the output:: >>> t = T(x0=-2, y0=-2, scale=2) >>> t(width=5, height=5) @@ -382,11 +387,23 @@ def T(x: X, y: Y, *, t0=0): return t - t0 +@ar.definition +def Cartesian(x: X, y: Y): + """Cartesian coordinates.""" + return np.dstack([x, y]) + + +@ar.definition +def Polar(r: R, t: T): + """Polar coordinates.""" + return np.dstack([r, t]) + + @ar.definition def Barycentric(x: X, y: Y, *, a=(0.5, -0.5), b=(0, 0.5), c=(-0.5, -0.5)): """Barycentric coordinates. - .. arlunio-image : : Barycentric Demo + .. arlunio-image:: Barycentric Demo :align: right :: diff --git a/arlunio/pattern.py b/arlunio/pattern.py index a263e176..fee7f8ce 100644 --- a/arlunio/pattern.py +++ b/arlunio/pattern.py @@ -90,7 +90,7 @@ def Template(x:X, y: Y): pattern = Grid(defn=Template(scale=1.)) image = fill( - pattern(width=512, height=512), background="#000", color="#ff0" + pattern(width=512, height=512), background="#000", foreground="#ff0" ) A checkerboard like pattern @@ -208,7 +208,7 @@ def Wall(width: int, height: int, *, sides=None): ]) map_ = Map(legend=legend, layout=layout) - image = fill(map_(width=1080, height=1080), color="blue") + image = fill(map_(width=1080, height=1080), foreground="blue") """ # TODO: Handle divisions with rounding errors @@ -324,7 +324,7 @@ def Ghost(x: X, y: Y): return (head(x=x, y=y) - eyes(x=np.abs(x), y=y)) + body ghost = Pixelize(defn=Ghost(y0=-0.3), n=32, m=32) - image = fill(ghost(width=1080, height=1080), color="#f00") + image = fill(ghost(width=1080, height=1080), foreground="#f00") """ if defn is None and pixels is None: diff --git a/arlunio/raytrace/camera.py b/arlunio/raytrace/camera.py index c2a0aa40..e7cd0c4f 100644 --- a/arlunio/raytrace/camera.py +++ b/arlunio/raytrace/camera.py @@ -55,7 +55,7 @@ def SimpleCamera( :math:`(0, 0, 0)` sampler: The sampler the camera should use in order to generate rays. If not set this - will default to the |UniformSampler| + will default to an instance of :class:`arlunio.raytrace.UniformSampler` scale: Something something scale. """ diff --git a/arlunio/shape.py b/arlunio/shape.py index 5e8d96e5..4856dbf2 100644 --- a/arlunio/shape.py +++ b/arlunio/shape.py @@ -68,7 +68,7 @@ def Target(width: int, height: int): for part, color in parts: image = fill( - part(width=width, height=height), color=color, image=image + part(width=width, height=height), foreground=color, image=image ) return image @@ -108,7 +108,7 @@ def OlympicRings(width: int, height: int, *, spacing=0.5, pt=0.025): for ring, color in rings: image = fill( - ring(width=width, height=height), color=color, image=image + ring(width=width, height=height), foreground=color, image=image ) return image @@ -320,10 +320,11 @@ def SuperEllipse( Examples -------- - Being a generalisation of the regular |Ellipse| definition most of the attributes - will have a similar effect on the outcome so be sure to check it out for additional - examples. For the :code:`SuperEllipse` definition the most interesting attributes - are :code:`n` and :code:`m` greatly affect the shape of the super ellipse. + Being a generalisation of the regular :class:`arlunio.shape.Ellipse` definition most + of the attributes will have a similar effect on the outcome so be sure to check it + out for additional examples. For the :code:`SuperEllipse` definition the most + interesting attributes are :code:`n` and :code:`m` greatly affect the shape of the + super ellipse. .. arlunio-image:: SuperEllipse Demo :include-code: @@ -349,7 +350,7 @@ def SuperEllipseDemo(width: int, height: int): for ellipse, color in ellipses: image = fill( - ellipse(width=1920, height=1080), color=color, image=image + ellipse(width=1920, height=1080), foreground=color, image=image ) return image @@ -383,7 +384,7 @@ def Sauron(width: int, height: int): for ellipse, color in ellipses: image = fill( - ellipse(width=1920, height=1080), color=color, image=image + ellipse(width=1920, height=1080), foreground=color, image=image ) return image diff --git a/arlunio/testing.py b/arlunio/testing.py index 7f5e44ee..2588f174 100644 --- a/arlunio/testing.py +++ b/arlunio/testing.py @@ -1,7 +1,13 @@ -from hypothesis.strategies import floats -from hypothesis.strategies import integers +"""Helpers and utilities for writing tests.""" +import hypothesis.strategies as st +import numpy as np +from hypothesis.extra import numpy -pve_num = floats(min_value=1, max_value=1e6) -real_num = floats(min_value=-1e6, max_value=1e6) +pve_num = st.floats(min_value=1, max_value=1e6) +real_num = st.floats(min_value=-1e6, max_value=1e6) -dimension = integers(min_value=4, max_value=512) +dimension = st.integers(min_value=4, max_value=512) + +mask = numpy.arrays( + dtype=np.bool_, shape=st.tuples(dimension, dimension), fill=st.booleans() +) diff --git a/changes/247.stdlib.rst b/changes/247.stdlib.rst new file mode 100644 index 00000000..1525f377 --- /dev/null +++ b/changes/247.stdlib.rst @@ -0,0 +1,8 @@ +Add :code:`arlunio.image.load` and :code:`arlunio.image.decode` functions to +mirror the existing save and encode functions. + +Update :code:`arlunio.image.Image` to now be a class in its own right, wrapping +a Pillow image object to add additional functionality + +Make the :code:`arlunio.image.fill` and :code:`arlunio.image.colorramp` functions +return RGBA images to make image composition easier. diff --git a/docs/_definitions.rst b/docs/_definitions.rst deleted file mode 100644 index f0782947..00000000 --- a/docs/_definitions.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. Code References - -.. |@definition| replace:: :py:func:`@definition ` -.. |Defn| replace:: :py:class:`Defn ` - -.. Math -.. |X| replace:: :py:class:`X ` -.. |Y| replace:: :py:class:`Y ` - - -.. Raytrace -.. |UniformSampler| replace:: :py:class:`UniformSampler ` - -.. Shape -.. |Circle| replace:: :py:class:`Circle ` -.. |Ellipse| replace:: :py:class:`Ellipse ` -.. |Rectangle| replace:: :py:class:`Rectangle ` -.. |Square| replace:: :py:class:`Square ` -.. |SuperEllipse| replace:: :py:class:`SuperEllipse ` - - -.. Docs References - -.. External Code References - -.. |numpy.logical_or| replace:: :data:`np.logical_or ` -.. |numpy.logical_and| replace:: :data:`np.logical_and ` - -.. |PIL.ImageColor| replace:: :py:mod:`PIL.ImageColor ` - -.. External Doc References - -.. |numpy.Broadcasting| replace:: :doc:`Broadcasting ` -.. |numpy.Array Broadcasting| replace:: :doc:`Array Broadcasting ` diff --git a/docs/conf.py b/docs/conf.py index c8c3db75..f66c4006 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,12 +55,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "_definitions.rst"] - -basedir = os.path.dirname(__file__) - -with open(os.path.join(basedir, "_definitions.rst")) as f: - rst_epilog = f.read() +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Internationalisation ---------------------------------------------------- @@ -92,7 +87,7 @@ intersphinx_mapping = { "pillow": ("https://pillow.readthedocs.io/en/stable/", None), "python": ("https://docs.python.org/3", None), - "numpy": ("https://docs.scipy.org/doc/numpy/", None), + "numpy": ("https://numpy.org/doc/stable/", None), } napoleon_use_rtype = False diff --git a/docs/stdlib/image.rst b/docs/stdlib/image.rst index 828799f0..3d995ddf 100644 --- a/docs/stdlib/image.rst +++ b/docs/stdlib/image.rst @@ -3,5 +3,37 @@ Image ===== -.. automodule:: arlunio.image - :members: \ No newline at end of file +.. currentmodule:: arlunio.image + +This module wraps various parts of the :doc:`pillow:index` image library to better +integrate it with other components of :code:`arlunio` in order to help produce and +manipulate images that can be saved eventually to disk. + +Creating Images +--------------- + +.. autofunction:: new + +.. autofunction:: fromarray + + +Image I/O +--------- + +.. autofunction:: encode + +.. autofunction:: decode + +.. autofunction:: load + +.. autofunction:: save + + +Manipulating Images +------------------- + +.. autofunction:: colorramp + +.. autofunction:: fill + +.. autoclass:: Image diff --git a/pytest.ini b/pytest.ini index 0e6de827..0d58cd90 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -addopts = --cov=arlunio --cov-report term +addopts = --doctest-modules --cov=arlunio --cov-report term filterwarnings = ignore::DeprecationWarning:jinja2 testpaths = arlunio docs tests diff --git a/tests/test_image.py b/tests/test_image.py index a8ef37fa..39506626 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,75 +1,177 @@ +import unittest.mock as mock + import numpy as np +import py.test +from hypothesis import given +from hypothesis import settings -from arlunio.image import colorramp -from arlunio.image import fill +import arlunio.image as image +import arlunio.testing as T -def test_colorramp_defaults(): - """Ensure that the colorramp method chooses sensible defaults.""" +@given(width=T.dimension, height=T.dimension) +def test_encode_decode(width, height): + """Ensure that if we encode an image, then decode it we end up with the same + thing.""" - values = np.array([[0.0, 0.5], [0.75, 1.0]]) - img = colorramp(values) + expected = image.new("RGBA", (width, height), color="red") + actual = image.decode(image.encode(expected)) - pix = np.array( - [[[0, 0, 0], [127, 127, 127]], [[191, 191, 191], [255, 255, 255]]], - dtype=np.uint8, - ) + assert expected == actual + + +class TestImage: + """Tests for our image class""" - assert (np.asarray(img) == pix).all() + @py.test.mark.parametrize("name", ["copy", "_repr_png_"]) + def test_pil_method_passthrough(self, name): + """We're not here to test pillow, but we should make sure we're using + it correctly""" + m_func = mock.MagicMock() + img = image.new("RGB", (4, 4), color="red") -def test_colorramp(): - """Ensure that the colorramp parses the colors it is given.""" + setattr(img.img, name, m_func) + getattr(img, name)() - values = np.array([[0.0, 0.5], [0.75, 1.0]]) - img = colorramp(values, start="#f00", stop="#0f0") + m_func.assert_called_once() - pix = np.array( - [[[255, 0, 0], [127, 127, 0]], [[63, 191, 0], [0, 255, 0]]], dtype=np.uint8 + @py.test.mark.parametrize( + "name,args,kwargs", + [ + ("alpha_composite", (1, 2, 3), {"a": "a", "b": "b"}), + ("save", (1, 2, 3), {"a": "a", "b": "b"}), + ("paste", (1, 2, 3), {"a": "a", "b": "b"}), + ], ) + def test_pil_method_args_passthrough(self, name, args, kwargs): + """We're not here to test pillow, but we should check to make sure we are using + it correctly.""" - assert (np.asarray(img) == pix).all() + m_func = mock.MagicMock() + img = image.new("RGB", (4, 4), color="red") + setattr(img.img, name, m_func) + getattr(img, name)(*args, **kwargs) -def test_fill_defaults(): - """Ensure that the fill method chooses sensible defaults.""" + m_func.assert_called_with(*args, **kwargs) - mask = np.array([[False, True], [True, False]]) - img = fill(mask) + @given(width=T.dimension, height=T.dimension) + def test_add(self, width, height): + """Ensure that 2 images can be added together, where adding is the same as an + alpha_composite.""" - pix = np.array( - [[[255, 255, 255], [0, 0, 0]], [[0, 0, 0], [255, 255, 255]]], dtype=np.uint8 - ) + a = image.new("RGBA", (width, height), color="black") + b = image.new("RGBA", (width // 2, height), color="red") - assert (np.asarray(img) == pix).all() + c = a + b + assert c is not a, "Addition should return a new image object" + d = a.copy() + d.alpha_composite(b) -def test_fill(): - """Ensure that the fill method parses colors that it is given.""" + assert c == d - mask = np.array([[False, True], [True, False]]) - img = fill(mask, color="#f00", background="#0f0") - pix = np.array( - [[[0, 255, 0], [255, 0, 0]], [[255, 0, 0], [0, 255, 0]]], dtype=np.uint8 - ) +class TestColorramp: + """Tests for the image.colorramp function.""" + + def test_with_defaults(self): + """Ensure that the colorramp method chooses sensible defaults.""" + + values = np.array([[0.0, 0.5], [0.75, 1.0]]) + img = image.colorramp(values) + + pix = np.array( + [ + [[0, 0, 0, 255], [127, 127, 127, 255]], + [[191, 191, 191, 255], [255, 255, 255, 255]], + ], + dtype=np.uint8, + ) + + assert (np.asarray(img) == pix).all() + + def test_colorramp(self): + """Ensure that the colorramp parses the colors it is given.""" + + values = np.array([[0.0, 0.5], [0.75, 1.0]]) + img = image.colorramp(values, start="#f00", stop="#0f0") + + pix = np.array( + [ + [[255, 0, 0, 255], [127, 127, 0, 255]], + [[63, 191, 0, 255], [0, 255, 0, 255]], + ], + dtype=np.uint8, + ) + + assert (np.asarray(img) == pix).all() - assert (np.asarray(img) == pix).all() +class TestFill: + """Tests for the image.fill function.""" -def test_fill_image(): - """Ensure that the fill method can use an existing image instead.""" + @settings(max_examples=50) + @given(mask=T.mask) + def test_with_defaults(self, mask): + """Ensure that the fill method chooses sensible defaults.""" - mask = np.array([[False, True], [True, False]]) - image = fill(mask) + img = image.fill(mask) + expected = np.full((*mask.shape, 4), (0, 0, 0, 0)) - mask = np.array([[True, True], [False, False]]) - new_image = fill(mask, color="#0f0", image=image) + if mask.any(): + expected[mask] = (0, 0, 0, 255) - assert image != new_image, "Function should return a new image" + assert (np.asarray(img) == expected).all() - pix = np.array( - [[[0, 255, 0], [0, 255, 0]], [[0, 0, 0], [255, 255, 255]]], dtype=np.uint8 + @py.test.mark.parametrize( + "fg,fgval,bg,bgval", + [ + ("red", (255, 0, 0, 255), "black", (0, 0, 0, 255)), + ("#ff0", (255, 255, 0, 255), "#00f", (0, 0, 255, 255)), + ("#f0f0", (255, 0, 255, 0), "#0f0f", (0, 255, 0, 255)), + ("#ff00ff", (255, 0, 255, 255), "#ffff00", (255, 255, 0, 255)), + ("#00ff00ff", (0, 255, 0, 255), "#ffff0000", (255, 255, 0, 0)), + ], ) + def test_with_colors(self, fg, fgval, bg, bgval): + """Ensure that the fill method can handles various color specifications.""" + + mask = np.array([[False, True], [True, False]]) + img = image.fill(mask, foreground=fg, background=bg) + + expected = np.full((*mask.shape, 4), bgval) + + if mask.any(): + expected[mask] = fgval + + assert (np.asarray(img) == expected).all() + + def test_fill_existing_rgb_image(self): + """Ensure that the fill method can use an existing RGB image.""" + + img = image.new("RGB", (2, 2), color="white") + mask = np.array([[True, True], [False, False]]) + + new_image = image.fill(mask, image=img) + assert image != new_image, "Function should return a new image" + + expected = np.full((*mask.shape, 3), 255) + expected[mask] = (0, 0, 0) + + assert (np.asarray(new_image) == expected).all() + + def test_fill_existing_rgba_image(self): + """Ensure that the fill method can use an existing RGBA image.""" + + img = image.new("RGBA", (2, 2), color="white") + mask = np.array([[True, True], [False, False]]) + + new_image = image.fill(mask, image=img) + assert image != new_image, "Function should return a new image" + + expected = np.full((*mask.shape, 4), 255) + expected[mask] = (0, 0, 0, 255) - assert (np.asarray(new_image) == pix).all() + assert (np.asarray(new_image) == expected).all()