From 1bc15769d8d3b30bcd2fc4ec4a477fc28d0b4a8d Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 18 May 2020 23:27:54 +0100 Subject: [PATCH] Migrate code from "core" into the standard library (#228) - New `arlunio.lib.image` module that now contains all image related code - Moved math related code to the `arlunio.lib.math` module --- arlunio/__init__.py | 8 +- arlunio/_expressions.py | 212 ---------------- arlunio/{_core.py => definition.py} | 0 arlunio/doc/directives.py | 4 +- arlunio/{imp/__init__.py => imp.py} | 0 arlunio/{_images.py => lib/image.py} | 4 +- arlunio/lib/mask/mask.py | 90 ------- arlunio/lib/mask/pattern.py | 37 +-- arlunio/lib/mask/shape.py | 84 ++++--- arlunio/lib/math.py | 227 +++++++++++++++++- arlunio/{testing/__init__.py => testing.py} | 0 changes/228.stdlib.rst | 4 + docs/api.rst | 37 +-- docs/stdlib/image.rst | 7 + docs/stdlib/index.rst | 3 +- docs/stdlib/{masks.rst => mask.rst} | 6 +- .../getting-started/your-first-image.rst | 4 +- setup.py | 1 + tests/doc/test_directives.py | 4 +- tests/{test_images.py => lib/test_image.py} | 15 +- tests/{test_core.py => test_definition.py} | 2 +- 21 files changed, 330 insertions(+), 419 deletions(-) delete mode 100644 arlunio/_expressions.py rename arlunio/{_core.py => definition.py} (100%) rename arlunio/{imp/__init__.py => imp.py} (100%) rename arlunio/{_images.py => lib/image.py} (97%) rename arlunio/{testing/__init__.py => testing.py} (100%) create mode 100644 changes/228.stdlib.rst create mode 100644 docs/stdlib/image.rst rename docs/stdlib/{masks.rst => mask.rst} (62%) rename tests/{test_images.py => lib/test_image.py} (85%) rename tests/{test_core.py => test_definition.py} (99%) diff --git a/arlunio/__init__.py b/arlunio/__init__.py index d74a3ac7..653d45d7 100644 --- a/arlunio/__init__.py +++ b/arlunio/__init__.py @@ -1,4 +1,4 @@ -from ._core import Defn, DefnInput, definition # noqa: F401 -from ._expressions import all, any, clamp, invert, lerp, normalise # noqa: F401 -from ._images import Image, Resolutions, colorramp, encode, fill, save # noqa: F401 -from ._version import __version__ # noqa: F401 +from ._version import __version__ +from .definition import Defn, DefnInput, definition + +__all__ = ["Defn", "DefnInput", "definition", "__version__"] diff --git a/arlunio/_expressions.py b/arlunio/_expressions.py deleted file mode 100644 index 167796e6..00000000 --- a/arlunio/_expressions.py +++ /dev/null @@ -1,212 +0,0 @@ -import functools - -from typing import Callable, Union - -import numpy as np - - -def any(*args: Union[bool, np.ndarray]) -> Union[bool, np.ndarray]: - """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. - - Parameters - ---------- - args: - A number of boolean conditions, a condition can either be a single boolean value - or a numpy array of boolean values. - - Examples - -------- - - >>> import arlunio as ar - >>> ar.any(True, False, False) - True - >>> ar.any(False, False, False, False) - False - - If the arguments are boolean numpy arrays, then the any condition is applied - element-wise - - >>> import numpy as np - >>> x1 = np.array([True, False, True]) - >>> x2 = np.array([False, False, True]) - >>> x3 = np.array([False, True, False]) - >>> ar.any(x1, x2, x3) - array([ 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. - - >>> ar.any(False, np.array([True, False]), np.array([[False, True], [True, False]])) - array([[ True, True], - [ True, False]]) - - - See Also - -------- - - |numpy.Broadcasting| - Numpy documentation on broadcasting. - - |numpy.Array Broadcasting| - Further background on broadcasting. - - |numpy.logical_or| - Reference documentation on the :code:`np.logical_or` function - """ - return functools.reduce(np.logical_or, args) - - -def all(*args: Union[bool, np.ndarray]) -> Union[bool, np.ndarray]: - """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. - - Parameters - ---------- - args: - A number of boolean conditions, a conditon can either be a single boolean value, - or a numpy array of boolean values. - - Examples - -------- - - >>> import arlunio as ar - >>> ar.all(True, True, True) - True - >>> ar.all(True, False, True, True) - False - - If the arguments are boolean numpy arrays, then the any condition is applied - element-wise - - >>> import numpy as np - >>> x1 = np.array([True, False, True]) - >>> x2 = np.array([False, False, True]) - >>> x3 = np.array([False, True, True]) - >>> ar.all(x1, x2, x3) - array([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. - - >>> ar.all(True, np.array([True, False]), np.array([[False, True], [True, False]])) - array([[False, False], - [ True, False]]) - - - See Also - -------- - - |numpy.Broadcasting| - Numpy documentation on broadcasting. - - |numpy.Array Broadcasting| - Further background on broadcasting. - - |numpy.logical_and| - Reference documentation on the :code:`logical_and` function. - """ - return functools.reduce(np.logical_and, args) - - -def clamp(values, min_value=0, max_value=1): - """Force an array of values to stay within a range of values. - - Parameters - ---------- - values: - The array of values to clamp - min_value: - The minimum value the result should contain - max_value: - The maximum value the resul should contain - - Examples - -------- - - By default values will be limited to between :code:`0` and :code:`1` - - >>> import arlunio as ar - >>> import numpy as np - >>> vs = np.linspace(-1, 2, 6) - >>> ar.clamp(vs) - array([0. , 0. , 0.2, 0.8, 1. , 1. ]) - - But this can be changed with extra arguments to the :code:`clamp` function - - >>> ar.clamp(vs, min_value=-1, max_value=0.5) - array([-1. , -0.4, 0.2, 0.5, 0.5, 0.5]) - """ - vs = np.array(values) - vs[vs > max_value] = max_value - vs[vs < min_value] = min_value - - return vs - - -def invert(x): - return np.logical_not(x) - - -def lerp(start: float = 0, stop: float = 1) -> Callable[[float], float]: - """Return a function that will linerarly interpolate between a and b. - - Parameters - ---------- - start: - The value the interpolation should start from. - stop: - The value the interpolation should stop at. - - Examples - -------- - - By default this function will interpolate between :code:`0` and :code:`1` - - >>> import arlunio as ar - >>> f = ar.lerp() - >>> f(0) - 0 - >>> f(1) - 1 - - However by passing arguments to the :code:`lerp` function we can change the bounds - of the interpolation. - - >>> import numpy as np - >>> ts = np.linspace(0, 1, 4) - >>> f = lerp(start=3, stop=-1) - >>> f(ts) - array([ 3. , 1.66666667, 0.33333333, -1. ]) - - """ - - def f(t: float) -> float: - return (1 - t) * start + t * stop - - return f - - -def normalise(x): - """Normalise an array into the range :math:`[0, 1]` - - Parameters - ---------- - x: - The array to normalise. - """ - minx = np.min(x) - vs = np.array(x) - - vs = vs - minx - return vs / np.max(vs) diff --git a/arlunio/_core.py b/arlunio/definition.py similarity index 100% rename from arlunio/_core.py rename to arlunio/definition.py diff --git a/arlunio/doc/directives.py b/arlunio/doc/directives.py index 5fc93818..e7149a87 100644 --- a/arlunio/doc/directives.py +++ b/arlunio/doc/directives.py @@ -4,7 +4,7 @@ from typing import List, Tuple -import arlunio as ar +import arlunio.lib.image as img from docutils import nodes from docutils.parsers import rst @@ -125,7 +125,7 @@ def render_image( if image is not None: context = { - "data": ar.encode(image).decode("utf-8"), + "data": img.encode(image).decode("utf-8"), "rendering": "auto" if smooth else "crisp-edges", } html = IMAGE_TEMPLATE.safe_substitute(context) diff --git a/arlunio/imp/__init__.py b/arlunio/imp.py similarity index 100% rename from arlunio/imp/__init__.py rename to arlunio/imp.py diff --git a/arlunio/_images.py b/arlunio/lib/image.py similarity index 97% rename from arlunio/_images.py rename to arlunio/lib/image.py index 35d1b942..20673ca6 100644 --- a/arlunio/_images.py +++ b/arlunio/lib/image.py @@ -10,7 +10,7 @@ import PIL.Image as PImage import PIL.ImageColor as PColor -from ._expressions import lerp, normalise +from arlunio.lib.math import lerp, normalise logger = logging.getLogger(__name__) @@ -24,7 +24,7 @@ class Resolutions(enum.Enum): Members of this enum are tuples containing the width and height which can be accessed by name:: - >>> from arlunio import Resolutions as R + >>> from arlunio.lib.image import Resolutions as R >>> hd = R.HD >>> hd.width diff --git a/arlunio/lib/mask/mask.py b/arlunio/lib/mask/mask.py index 264d40b2..f852c2b9 100644 --- a/arlunio/lib/mask/mask.py +++ b/arlunio/lib/mask/mask.py @@ -21,36 +21,6 @@ def MaskAdd( The first mask b: The second mask - - Examples - -------- - This definition is used implicitly when adding two masks together - - .. arlunio-image:: - :include-code: before - - import arlunio as ar - from arlunio.lib import Circle, Square - - c1 = Square(xc=-0.25, yc=-0.25, size=0.55) - c2 = Circle(xc=0.25, yc=0.25, r=0.7) - - c = c1 + c2 - image = ar.fill(c(width=1920, height=1080)) - - Or can be used directly - - .. arlunio-image:: - :include-code: before - - import arlunio as ar - from arlunio.lib import Circle, MaskAdd, Square - - a = Square(xc=-0.25, yc=-0.25, size=0.55) - b = Circle(xc=0.25, yc=0.25, r=0.7) - - c = MaskAdd(a=a, b=b) - image = ar.fill(c(width=1920, height=1080)) """ return ar.any(a(width=width, height=height), b(width=width, height=height)) @@ -76,36 +46,6 @@ def MaskSub( The first "base" mask b: The second mask that defines the region to remove from :code:`a` - - Examples - -------- - This definition is used implicitly when subtracting one mask from another - - .. arlunio-image:: - :include-code: before - - import arlunio as ar - from arlunio.lib import Circle, Square - - c1 = Square(xc=-0.25, yc=-0.25, size=0.55) - c2 = Circle(xc=0.25, yc=0.25, r=0.7) - - c = c1 - c2 - image = ar.fill(c(width=1920, height=1080)) - - Or can be used directly - - .. arlunio-image:: - :include-code: before - - import arlunio as ar - from arlunio.lib import Circle, MaskSub, Square - - a = Square(xc=-0.25, yc=-0.25, size=0.55) - b = Circle(xc=0.25, yc=0.25, r=0.7) - - c = MaskSub(a=b, b=a) - image = ar.fill(c(width=1920, height=1080)) """ return ar.all( a(width=width, height=height), ar.invert(b(width=width, height=height)) @@ -127,35 +67,5 @@ def MaskMul( The first mask b: The second mask - - Examples - -------- - This definition is used implicitly when multiplying two masks together - - .. arlunio-image:: - :include-code: before - - import arlunio as ar - from arlunio.lib import Circle, Square - - c1 = Square(xc=-0.25, yc=-0.25, size=0.55) - c2 = Circle(xc=0.25, yc=0.25, r=0.7) - - c = c1 * c2 - image = ar.fill(c(width=1920, height=1080)) - - Or can be used directly - - .. arlunio-image:: - :include-code: before - - import arlunio as ar - from arlunio.lib import Circle, MaskMul, Square - - a = Square(xc=-0.25, yc=-0.25, size=0.55) - b = Circle(xc=0.25, yc=0.25, r=0.7) - - c = MaskMul(a=a, b=b) - image = ar.fill(c(width=1920, height=1080)) """ return ar.all(a(width=width, height=height), b(width=width, height=height)) diff --git a/arlunio/lib/mask/pattern.py b/arlunio/lib/mask/pattern.py index a669a3ac..74960c8f 100644 --- a/arlunio/lib/mask/pattern.py +++ b/arlunio/lib/mask/pattern.py @@ -13,11 +13,11 @@ def Grid(width: int, height: int, *, n=4, m=None, defn=None) -> Mask: """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.mask import Circle, Grid + from arlunio.lib.image import fill pattern = Grid(defn=Circle()) - image = ar.fill(pattern(width=1920, height=1080)) + image = fill(pattern(width=1920, height=1080)) Repeatedly draw the given defintition in a grid. @@ -53,6 +53,7 @@ def Grid(width: int, height: int, *, n=4, m=None, defn=None) -> Mask: from arlunio.lib.mask import Circle, Grid from arlunio.lib.math import X, Y + from arlunio.lib.image import fill @ar.definition def Template(x:X, y: Y): @@ -60,7 +61,7 @@ def Template(x:X, y: Y): return c(x=np.abs(x), y=np.abs(y)) pattern = Grid(defn=Template(scale=1.)) - image = ar.fill( + image = fill( pattern(width=1080, height=1080), background="#000", color="#ff0" ) @@ -74,13 +75,14 @@ def Template(x:X, y: Y): from arlunio.lib.mask import Grid from arlunio.lib.math import X, Y + from arlunio.lib.image import fill @ar.definition def Template(x: X, y: Y): return np.abs(x) - np.abs(y) < 0 grid = Grid(defn=Template(), n=22, m=13) - image = ar.fill(grid(width=1920, height=1080)) + image = fill(grid(width=1920, height=1080)) """ if m is None: m = n @@ -130,6 +132,8 @@ def Map(width: int, height: int, *, layout=None, legend=None) -> Mask: import numpy as np from arlunio.lib.mask import Empty, Map, Rectangle + from arlunio.lib.math import any_ + from arlunio.lib.image import fill @ar.definition def Wall(width: int, height: int, *, sides=None): @@ -145,7 +149,7 @@ def Wall(width: int, height: int, *, sides=None): mask = False for side in sides.split('-'): wall = Rectangle(size=0.2, **walls[side]) - mask = ar.any(mask, wall(width=width, height=height)) + mask = any_(mask, wall(width=width, height=height)) return mask @@ -170,7 +174,7 @@ def Wall(width: int, height: int, *, sides=None): ]) map_ = Map(legend=legend, layout=layout) - image = ar.fill(map_(width=1080, height=1080), color="blue") + image = fill(map_(width=1080, height=1080), color="blue") """ # TODO: Handle divisions with rounding errors @@ -190,11 +194,11 @@ def Pixelize( """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.mask import Circle, Pixelize + from arlunio.lib.image import fill pix = Pixelize(defn=Circle(), n=32, m=32) - image = ar.fill(pix(width=1920, height=1080)) + image = fill(pix(width=1920, height=1080)) Draw a pixelated version of a definition. @@ -232,10 +236,10 @@ def Pixelize( .. arlunio-image:: :include-code: before - import arlunio as ar import numpy as np from arlunio.lib.mask import Pixelize + from arlunio.lib.image import fill pixels = np.array([ [False, True, True, False], @@ -244,7 +248,7 @@ def Pixelize( [False, True, True, False] ]) defn = Pixelize(pixels=pixels) - image = ar.fill(defn(width=1080, height=1080)) + image = fill(defn(width=1080, height=1080)) Alternatively we can generate the pixels from an instance of another definition @@ -255,29 +259,30 @@ def Pixelize( import numpy as np from arlunio.lib.mask import Circle, Pixelize - from arlunio.lib.math import X, Y + from arlunio.lib.math import X, Y, all_, any_, invert + from arlunio.lib.image import fill @ar.definition def Ghost(x: X, y: Y): head = Circle(yc=0.5, r=0.7) eyes = Circle(xc=0.2, yc=0.6, r=0.3) - body = ar.all( + body = all_( y < 0.5, np.abs(x) < 0.49, 0.1 * np.cos(5 * np.pi * x) - 0.3 < y ) - return ar.any( - ar.all( + return any_( + all_( head(x=x, y=y), - ar.invert(eyes(x=np.abs(x), y=y)) + invert(eyes(x=np.abs(x), y=y)) ), body ) ghost = Pixelize(defn=Ghost(y0=-0.3), n=32, m=32) - image = ar.fill(ghost(width=1080, height=1080), color="#f00") + image = fill(ghost(width=1080, height=1080), color="#f00") """ if defn is None and pixels is None: diff --git a/arlunio/lib/mask/shape.py b/arlunio/lib/mask/shape.py index 36bfc43d..8e50c1fd 100644 --- a/arlunio/lib/mask/shape.py +++ b/arlunio/lib/mask/shape.py @@ -4,7 +4,7 @@ import arlunio as ar import numpy as np -from arlunio.lib.math import X, Y +from arlunio.lib.math import X, Y, all_, invert from .mask import Mask @@ -14,11 +14,11 @@ def Circle(x: X, y: Y, *, xc=0, yc=0, r=0.8, pt=None) -> Mask: """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.mask import Circle + from arlunio.lib.image import fill circle = Circle() - image = ar.fill(circle(width=1920, height=1080)) + image = fill(circle(width=1920, height=1080)) We define a circle using the following equality. @@ -51,7 +51,9 @@ def Circle(x: X, y: Y, *, xc=0, yc=0, r=0.8, pt=None) -> Mask: :include-code: before import arlunio as ar + from arlunio.lib.mask import Circle + from arlunio.lib.image import fill @ar.definition def Target(width: int, height: int): @@ -64,7 +66,7 @@ def Target(width: int, height: int): ] for part, color in parts: - image = ar.fill( + image = fill( part(width=width, height=height), color=color, image=image ) @@ -80,7 +82,9 @@ def Target(width: int, height: int): :include-code: before import arlunio as ar + from arlunio.lib.mask import Circle + from arlunio.lib.image import fill @ar.definition def OlympicRings(width: int, height: int, *, spacing=0.5, pt=0.025): @@ -99,7 +103,7 @@ def OlympicRings(width: int, height: int, *, spacing=0.5, pt=0.025): ] for ring, color in rings: - image = ar.fill( + image = fill( ring(width=width, height=height), color=color, image=image ) @@ -118,7 +122,7 @@ def OlympicRings(width: int, height: int, *, spacing=0.5, pt=0.025): inner = (1 - pt) * r ** 2 outer = (1 + pt) * r ** 2 - return ar.all(inner < circle, circle < outer) + return all_(inner < circle, circle < outer) @ar.definition @@ -126,11 +130,11 @@ def Ellipse(x: X, y: Y, *, xc=0, yc=0, a=2, b=1, r=0.8, pt=None) -> Mask: """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.mask import Ellipse + from arlunio.lib.image import fill ellipse = Ellipse() - image = ar.fill(ellipse(width=1920, height=1080)) + image = fill(ellipse(width=1920, height=1080)) An ellipse can be defined using the following equality. @@ -176,7 +180,9 @@ def Ellipse(x: X, y: Y, *, xc=0, yc=0, a=2, b=1, r=0.8, pt=None) -> Mask: :include-code: before import arlunio as ar + from arlunio.lib.mask import Ellipse + from arlunio.lib.image import fill @ar.definition def EllipseDemo(width: int, height: int): @@ -192,7 +198,7 @@ def EllipseDemo(width: int, height: int): ] for ellipse in ellipses: - image = ar.fill(ellipse(width=1920, height=1080), image=image) + image = fill(ellipse(width=1920, height=1080), image=image) return image demo = EllipseDemo() @@ -205,8 +211,10 @@ def EllipseDemo(width: int, height: int): :include-code: before import arlunio as ar + from arlunio.lib.mask import Ellipse from arlunio.lib.math import X, Y + from arlunio.lib.image import fill @ar.definition def Atom(x: X, y: Y): @@ -221,7 +229,7 @@ def Atom(x: X, y: Y): ] for ellipse, ex, ey in ellipses: - image = ar.fill(ellipse(x=ex, y=ey), image=image) + image = fill(ellipse(x=ex, y=ey), image=image) return image @@ -243,7 +251,7 @@ def Atom(x: X, y: Y): inner = (1 - pt) * r ** 2 outer = (1 + pt) * r ** 2 - return ar.all(inner < ellipse, ellipse < outer) + return all_(inner < ellipse, ellipse < outer) @ar.definition @@ -253,11 +261,11 @@ def SuperEllipse( """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.mask import SuperEllipse + from arlunio.lib.image import fill ellipse = SuperEllipse() - image = ar.fill(ellipse(width=1920, height=1080)) + image = fill(ellipse(width=1920, height=1080)) We define a `SuperEllipse`_ by the following equality. @@ -307,7 +315,9 @@ def SuperEllipse( :include-code: before import arlunio as ar + from arlunio.lib.mask import SuperEllipse + from arlunio.lib.image import fill @ar.definition def SuperEllipseDemo(width: int, height: int): @@ -321,7 +331,7 @@ def SuperEllipseDemo(width: int, height: int): ] for ellipse, color in ellipses: - image = ar.fill( + image = fill( ellipse(width=1920, height=1080), color=color, image=image ) @@ -338,7 +348,9 @@ def SuperEllipseDemo(width: int, height: int): :include-code: before import arlunio as ar + from arlunio.lib.mask import SuperEllipse + from arlunio.lib.image import fill @ar.definition def Sauron(width: int, height: int): @@ -350,7 +362,7 @@ def Sauron(width: int, height: int): ] for ellipse, color in ellipses: - image = ar.fill( + image = fill( ellipse(width=1920, height=1080), color=color, image=image ) @@ -377,7 +389,7 @@ def Sauron(width: int, height: int): inner = (1 - pt) * r outer = (1 + pt) * r - return ar.all(inner < ellipse, ellipse < outer) + return all_(inner < ellipse, ellipse < outer) @ar.definition @@ -390,11 +402,11 @@ def Empty(width: int, height: int) -> Mask: .. arlunio-image:: :include-code: before - import arlunio as ar from arlunio.lib.mask import Empty + from arlunio.lib.image import fill e = Empty() - image = ar.fill(e(width=1920, height=1080)) + image = fill(e(width=1920, height=1080)) """ return np.full((height, width), False) @@ -409,11 +421,11 @@ def Full(width: int, height: int) -> Mask: .. arlunio-image:: :include-code: before - import arlunio as ar from arlunio.lib.mask import Full + from arlunio.lib.image import fill f = Full() - image = ar.fill(f(width=1920, height=1080)) + image = fill(f(width=1920, height=1080)) """ return np.full((height, width), True) @@ -423,11 +435,11 @@ def Square(x: X, y: Y, *, xc=0, yc=0, size=0.8, pt=None) -> Mask: """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.mask import Square + from arlunio.lib.image import fill square = Square() - image = ar.fill(square(width=1920, height=1080)) + image = fill(square(width=1920, height=1080)) A square. @@ -452,8 +464,10 @@ def Square(x: X, y: Y, *, xc=0, yc=0, size=0.8, pt=None) -> Mask: :include-code: before import arlunio as ar + from arlunio.lib.mask import Square from arlunio.lib.math import X, Y + from arlunio.lib.image import fill @ar.definition def SquareDemo(x: X, y: Y): @@ -467,7 +481,7 @@ def SquareDemo(x: X, y: Y): ] for square, sx, sy in squares: - image = ar.fill(square(x=sx, y=sy), image=image) + image = fill(square(x=sx, y=sy), image=image) return image @@ -479,15 +493,15 @@ def SquareDemo(x: X, y: Y): ys = np.abs(y - yc) if pt is None: - return ar.all(xs < size, ys < size) + return all_(xs < size, ys < size) s = (1 - pt) * size S = (1 + pt) * size - inner = ar.all(xs < s, ys < s) - outer = ar.all(xs < S, ys < S) + inner = all_(xs < s, ys < s) + outer = all_(xs < S, ys < S) - return ar.all(outer, ar.invert(inner)) + return all_(outer, invert(inner)) @ar.definition @@ -495,11 +509,11 @@ def Rectangle(x: X, y: Y, *, xc=0, yc=0, size=0.6, ratio=1.618, pt=None) -> Mask """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.mask import Rectangle + from arlunio.lib.image import fill rectangle = Rectangle() - image = ar.fill(rectangle(width=1920, height=1080)) + image = fill(rectangle(width=1920, height=1080)) A Rectangle. @@ -526,7 +540,9 @@ def Rectangle(x: X, y: Y, *, xc=0, yc=0, size=0.6, ratio=1.618, pt=None) -> Mask :include-code: before import arlunio as ar + from arlunio.lib.mask import Rectangle + from arlunio.lib.image import fill @ar.definition def RectangleDemo(width: int, height: int): @@ -538,7 +554,7 @@ def RectangleDemo(width: int, height: int): ] for r in rects: - image = ar.fill(r(width=width, height=height), image=image) + image = fill(r(width=width, height=height), image=image) return image @@ -553,12 +569,12 @@ def RectangleDemo(width: int, height: int): width = height * ratio if pt is None: - return ar.all(xs < width, ys < height) + return all_(xs < width, ys < height) w, W = (1 - pt) * width, (1 + pt) * width h, H = (1 - pt) * height, (1 + pt) * height - inner = ar.all(xs < w, ys < h) - outer = ar.all(xs < W, ys < H) + inner = all_(xs < w, ys < h) + outer = all_(xs < W, ys < H) - return ar.all(outer, ar.invert(inner)) + return all_(outer, invert(inner)) diff --git a/arlunio/lib/math.py b/arlunio/lib/math.py index 028c112e..f34787af 100644 --- a/arlunio/lib/math.py +++ b/arlunio/lib/math.py @@ -1,17 +1,228 @@ +import functools + +from typing import Callable, Union + import arlunio as ar import numpy as np +def any_(*args: Union[bool, np.ndarray]) -> Union[bool, np.ndarray]: + """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. + + Parameters + ---------- + args: + A number of boolean conditions, a condition can either be a single boolean value + or a numpy array of boolean values. + + Examples + -------- + + >>> from arlunio.lib.math import any_ + >>> any_(True, False, False) + True + >>> any_(False, False, False, False) + False + + If the arguments are boolean numpy arrays, then the any condition is applied + element-wise + + >>> import numpy as np + >>> x1 = np.array([True, False, True]) + >>> x2 = np.array([False, False, True]) + >>> x3 = np.array([False, True, False]) + >>> any_(x1, x2, x3) + array([ 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. + + >>> any_(False, np.array([True, False]), np.array([[False, True], [True, False]])) + array([[ True, True], + [ True, False]]) + + + See Also + -------- + + |numpy.Broadcasting| + Numpy documentation on broadcasting. + + |numpy.Array Broadcasting| + Further background on broadcasting. + + |numpy.logical_or| + Reference documentation on the :code:`np.logical_or` function + """ + return functools.reduce(np.logical_or, args) + + +def all_(*args: Union[bool, np.ndarray]) -> Union[bool, np.ndarray]: + """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. + + Parameters + ---------- + args: + A number of boolean conditions, a conditon can either be a single boolean value, + or a numpy array of boolean values. + + Examples + -------- + + >>> from arlunio.lib.math import all_ + >>> all_(True, True, True) + True + >>> all_(True, False, True, True) + False + + If the arguments are boolean numpy arrays, then the any condition is applied + element-wise + + >>> import numpy as np + >>> x1 = np.array([True, False, True]) + >>> x2 = np.array([False, False, True]) + >>> x3 = np.array([False, True, True]) + >>> all_(x1, x2, x3) + array([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. + + >>> all_(True, np.array([True, False]), np.array([[False, True], [True, False]])) + array([[False, False], + [ True, False]]) + + + See Also + -------- + + |numpy.Broadcasting| + Numpy documentation on broadcasting. + + |numpy.Array Broadcasting| + Further background on broadcasting. + + |numpy.logical_and| + Reference documentation on the :code:`logical_and` function. + """ + return functools.reduce(np.logical_and, args) + + +def clamp(values, min_value=0, max_value=1): + """Force an array of values to stay within a range of values. + + Parameters + ---------- + values: + The array of values to clamp + min_value: + The minimum value the result should contain + max_value: + The maximum value the resul should contain + + Examples + -------- + + By default values will be limited to between :code:`0` and :code:`1` + + >>> from arlunio.lib.math import clamp + >>> import numpy as np + >>> vs = np.linspace(-1, 2, 6) + >>> clamp(vs) + array([0. , 0. , 0.2, 0.8, 1. , 1. ]) + + But this can be changed with extra arguments to the :code:`clamp` function + + >>> clamp(vs, min_value=-1, max_value=0.5) + array([-1. , -0.4, 0.2, 0.5, 0.5, 0.5]) + """ + vs = np.array(values) + vs[vs > max_value] = max_value + vs[vs < min_value] = min_value + + return vs + + +def invert(x): + return np.logical_not(x) + + +def lerp(start: float = 0, stop: float = 1) -> Callable[[float], float]: + """Return a function that will linerarly interpolate between a and b. + + Parameters + ---------- + start: + The value the interpolation should start from. + stop: + The value the interpolation should stop at. + + Examples + -------- + + By default this function will interpolate between :code:`0` and :code:`1` + + >>> from arlunio.lib.math import lerp + >>> f = lerp() + >>> f(0) + 0 + >>> f(1) + 1 + + However by passing arguments to the :code:`lerp` function we can change the bounds + of the interpolation. + + >>> import numpy as np + >>> ts = np.linspace(0, 1, 4) + >>> f = lerp(start=3, stop=-1) + >>> f(ts) + array([ 3. , 1.66666667, 0.33333333, -1. ]) + + """ + + def f(t: float) -> float: + return (1 - t) * start + t * stop + + return f + + +def normalise(vs): + """Normalise an array into the range :math:`[0, 1]` + + Parameters + ---------- + vs: + The array to normalise. + """ + minx = np.min(vs) + vs = np.array(vs) + + vs = vs - minx + return vs / np.max(vs) + + @ar.definition def X(width: int, height: int, *, x0=0, scale=1, stretch=False): """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.math import X + from arlunio.lib.image import colorramp x = X() - image = ar.colorramp(x(width=1920, height=1080)) + image = colorramp(x(width=1920, height=1080)) Cartesian :math:`x` coordinates. @@ -82,11 +293,11 @@ def Y(width: int, height: int, *, y0=0, scale=1, stretch=False): """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.math import Y + from arlunio.lib.image import colorramp y = Y() - image = ar.colorramp(y(width=1920, height=1080)) + image = colorramp(y(width=1920, height=1080)) Cartesian :math:`y` coordinates. @@ -161,11 +372,11 @@ def R(x: X, y: Y): """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.math import R + from arlunio.lib.image import colorramp r = R() - image = ar.colorramp(r(width=1920, height=1080)) + image = colorramp(r(width=1920, height=1080)) Polar :math:`r` coordinates. @@ -214,11 +425,11 @@ def T(x: X, y: Y, *, t0=0): """ .. arlunio-image:: - import arlunio as ar from arlunio.lib.math import T + from arlunio.lib.image import colorramp t = T() - image = ar.colorramp(t(width=1920, height=1080)) + image = colorramp(t(width=1920, height=1080)) Polar, :math:`t` coordinates. diff --git a/arlunio/testing/__init__.py b/arlunio/testing.py similarity index 100% rename from arlunio/testing/__init__.py rename to arlunio/testing.py diff --git a/changes/228.stdlib.rst b/changes/228.stdlib.rst new file mode 100644 index 00000000..f0f7e9a2 --- /dev/null +++ b/changes/228.stdlib.rst @@ -0,0 +1,4 @@ +Moved the "expression" based code out of core into the :code:`arlunio.lib.math` module. + +New :code:`arlunio.lib.image` module. This contains the image code that was originally +part of "core" arlunio. diff --git a/docs/api.rst b/docs/api.rst index 0ecaa206..72178bd2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,39 +3,4 @@ API Reference ============= -Definitions ------------ - -- |@definition|: Decorator used to create new definitions -- |Defn|: Base class for all definitions - -.. autodecorator:: arlunio.definition - -.. autoclass:: arlunio.Defn - :members: - - -Expressions ------------ - -.. autofunction:: arlunio.any - -.. autofunction:: arlunio.all - -.. autofunction:: arlunio.clamp - -.. autofunction:: arlunio.invert - -.. autofunction:: arlunio.lerp - -.. autofunction:: arlunio.normalise - -Images ------- - -.. autoclass:: arlunio.Resolutions - :members: - -.. autofunction:: arlunio.colorramp - -.. autofunction:: arlunio.fill +.. automodule:: arlunio diff --git a/docs/stdlib/image.rst b/docs/stdlib/image.rst new file mode 100644 index 00000000..bd853d10 --- /dev/null +++ b/docs/stdlib/image.rst @@ -0,0 +1,7 @@ +.. _stdlib_image: + +Image +===== + +.. automodule:: arlunio.lib.image + :members: \ No newline at end of file diff --git a/docs/stdlib/index.rst b/docs/stdlib/index.rst index 743ff060..b01817b6 100644 --- a/docs/stdlib/index.rst +++ b/docs/stdlib/index.rst @@ -12,5 +12,6 @@ basics everyone will need along with some useful utilities for various tasks. :maxdepth: 2 :caption: Index - masks + image + mask math diff --git a/docs/stdlib/masks.rst b/docs/stdlib/mask.rst similarity index 62% rename from docs/stdlib/masks.rst rename to docs/stdlib/mask.rst index b90a6881..29afb336 100644 --- a/docs/stdlib/masks.rst +++ b/docs/stdlib/mask.rst @@ -1,5 +1,7 @@ -Masks -===== +.. _stdlib_mask: + +Mask +==== .. automodule:: arlunio.lib.mask :members: diff --git a/docs/users/getting-started/your-first-image.rst b/docs/users/getting-started/your-first-image.rst index 4d422a8c..f48da0bd 100644 --- a/docs/users/getting-started/your-first-image.rst +++ b/docs/users/getting-started/your-first-image.rst @@ -18,8 +18,8 @@ code:: .. arlunio-image:: - import arlunio as ar from arlunio.lib.mask import Circle + from arlunio.lib.image import fill circle = Circle() - image = ar.fill(circle(width=1920, height=1080), color="red") + image = fill(circle(width=1920, height=1080), color="red") diff --git a/setup.py b/setup.py index 06c40e92..3ccf25e7 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ def readme(): "dev": [ "black", "flake8", + "hypothesis", "jupyterlab", "pre-commit", "pytest", diff --git a/tests/doc/test_directives.py b/tests/doc/test_directives.py index 6afa7e65..51ae8fd0 100644 --- a/tests/doc/test_directives.py +++ b/tests/doc/test_directives.py @@ -54,11 +54,11 @@ def test_render_image_image_provided(): """Ensure that if the code provides an image we use that.""" src = """\ - import arlunio as ar from arlunio.lib.mask import Circle + from arlunio.lib.image import fill circle = Circle() - disk = ar.fill(circle(width=4, height=4)) + disk = fill(circle(width=4, height=4)) """ doctree = render_image(textwrap.dedent(src)) diff --git a/tests/test_images.py b/tests/lib/test_image.py similarity index 85% rename from tests/test_images.py rename to tests/lib/test_image.py index ccf9cc66..a11f3456 100644 --- a/tests/test_images.py +++ b/tests/lib/test_image.py @@ -1,12 +1,13 @@ -import arlunio as ar import numpy as np +from arlunio.lib.image import colorramp, fill + def test_colorramp_defaults(): """Ensure that the colorramp method chooses sensible defaults.""" values = np.array([[0.0, 0.5], [0.75, 1.0]]) - img = ar.colorramp(values) + img = colorramp(values) pix = np.array( [[[0, 0, 0], [127, 127, 127]], [[191, 191, 191], [255, 255, 255]]], @@ -20,7 +21,7 @@ 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 = colorramp(values, start="#f00", stop="#0f0") pix = np.array( [[[255, 0, 0], [127, 127, 0]], [[63, 191, 0], [0, 255, 0]]], dtype=np.uint8 @@ -33,7 +34,7 @@ def test_fill_defaults(): """Ensure that the fill method chooses sensible defaults.""" mask = np.array([[False, True], [True, False]]) - img = ar.fill(mask) + img = fill(mask) pix = np.array( [[[255, 255, 255], [0, 0, 0]], [[0, 0, 0], [255, 255, 255]]], dtype=np.uint8 @@ -46,7 +47,7 @@ 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 = fill(mask, color="#f00", background="#0f0") pix = np.array( [[[0, 255, 0], [255, 0, 0]], [[255, 0, 0], [0, 255, 0]]], dtype=np.uint8 @@ -59,10 +60,10 @@ def test_fill_image(): """Ensure that the fill method can use an existing image instead.""" mask = np.array([[False, True], [True, False]]) - image = ar.fill(mask) + image = fill(mask) mask = np.array([[True, True], [False, False]]) - new_image = ar.fill(mask, color="#0f0", image=image) + new_image = fill(mask, color="#0f0", image=image) assert image != new_image, "Function should return a new image" diff --git a/tests/test_core.py b/tests/test_definition.py similarity index 99% rename from tests/test_core.py rename to tests/test_definition.py index 59428066..892447a8 100644 --- a/tests/test_core.py +++ b/tests/test_definition.py @@ -24,7 +24,7 @@ def test_definition_module(): def Circle(): pass - assert Circle.__module__ == "tests.test_core" + assert Circle.__module__ == "tests.test_definition" def test_definition_constant():