diff --git a/.gitignore b/.gitignore index 8c883404..ff6f2b80 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ .coverage .hypothesis .ipynb_checkpoints/ -.scratch .pytest_cache .mypy_cache .tox diff --git a/.scratch/.gitignore b/.scratch/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/.scratch/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 4fcacb13..9c40aa6a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,7 +9,6 @@ "build": true, "dist": true, "pip-wheel-metadata": true, - ".dev": true, "htmlcov": true, ".coverage": true, "coverage.xml": true, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 634e36d3..65852636 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -22,53 +22,34 @@ } }, { - "label": "Build Docs", + "label": "Jupyter", "type": "shell", - "command": "source ${workspaceRoot}/.env/bin/activate && sphinx-autobuild -E -a -b html docs/ docs/_build/", + "command": "source ${workspaceRoot}/.env/bin/activate && jupyter-lab ${input:jupyterArgs}", "problemMatcher": [], "group": "build", + "isBackground": true, "options": { - "cwd": "${workspaceRoot}" - } - }, - { - "label": "Build Tutorial", - "type": "shell", - "command": "source ${workspaceRoot}/.env/bin/activate && sphinx-build -M nbtutorial docs/ docs/_build/ -E -a", - "problemMatcher": [], - "group": "build", - "options": { - "cwd": "${workspaceRoot}" + "cwd": "${input:jupyterWd}" } }, { - "label": "Edit Gallery", - "type": "shell", - "command": "source ${workspaceRoot}/.env/bin/activate && jupyter-lab --no-browser", - "problemMatcher": [], - "group": "build", - "options": { - "cwd": "${workspaceRoot}/blog/src/gallery" - } - }, - { - "label": "Edit Gallery (browser)", + "label": "Preview Blog", "type": "shell", - "command": "source ${workspaceRoot}/.env/bin/activate && jupyter-lab", + "command": "${config:python.pythonPath} -m http.server 8001", "problemMatcher": [], "group": "build", "options": { - "cwd": "${workspaceRoot}/blog/src/gallery" + "cwd": "${workspaceRoot}/blog/public" } }, { - "label": "Preview Blog", + "label": "Sphinx", "type": "shell", - "command": "${config:python.pythonPath} -m http.server 8001", + "command": "source ${workspaceRoot}/.env/bin/activate && make ${input:builder}", "problemMatcher": [], "group": "build", "options": { - "cwd": "${workspaceRoot}/blog/public" + "cwd": "${workspaceRoot}/docs" } }, { @@ -103,36 +84,6 @@ "options": { "cwd": "${workspaceRoot}" } - }, - { - "label": "Scratch Notebooks", - "type": "shell", - "command": "source ${workspaceRoot}/.env/bin/activate && jupyter-lab", - "problemMatcher": [], - "group": "build", - "options": { - "cwd": "${workspaceRoot}/.scratch" - } - }, - { - "label": "Open Tutorial", - "type": "shell", - "command": "source ${workspaceRoot}/.env/bin/activate && jupyter-lab", - "problemMatcher": [], - "group": "build", - "options": { - "cwd": "${workspaceRoot}/docs/_build/nbtutorial/users" - } - }, - { - "label": "Test File", - "type": "shell", - "command": "${config:python.pythonPath} -m tox -e py38 -- ${file}", - "problemMatcher": [], - "group": "test", - "options": { - "cwd": "${workspaceRoot}" - } } ], "inputs": [ @@ -168,10 +119,39 @@ ], "default": "-e py38" }, + { + "id": "builder", + "description": "Sphinx builders", + "type": "pickString", + "default": "html", + "options": [ + { + "label": "Build HTML Docs", + "value": "html" + }, + { + "label": "Extract gallery images", + "value": "nbgallery" + }, + { + "label": "Extract tutorial", + "value": "nbtutorial" + }, + { + "label": "Test Examples", + "value": "doctest" + }, + { + "label": "Test Links", + "value": "linkcheck" + } + ] + }, { "id": "blogArgs", "description": "Blog Arguments", "type": "pickString", + "default": "--local", "options": [ { "label": "Local", @@ -189,8 +169,39 @@ "label": "Local, debug, skipping errors", "value": "-vv --local --skip-failures" } - ], - "default": "--local" + ] + }, + { + "id": "jupyterArgs", + "description": "Jupyter Arguments", + "type": "pickString", + "default": "", + "options": [ + { + "label": "Headless", + "value": "--no-browser" + }, + { + "label": "In Browser", + "value": "" + } + ] + }, + { + "id": "jupyterWd", + "description": "Working directory for jupyter", + "type": "pickString", + "default": ".scratch", + "options": [ + { + "label": "Scratch Folder", + "value": ".scratch" + }, + { + "label": "Gallery", + "value": "blog/src/gallery" + } + ] } ] } \ No newline at end of file diff --git a/arlunio/image.py b/arlunio/image.py index d8a21f4c..0334eed7 100644 --- a/arlunio/image.py +++ b/arlunio/image.py @@ -98,12 +98,35 @@ def thumbnail(self, *args, **kwargs): self.img.thumbnail(*args, **kwargs) -def new(*args, **kwargs): - """Creates a new image with the given mode and size +def new(size, *args, mode="RGBA", **kwargs) -> Image: + """Creates a new image with the given size. + + This function by default will return a new :code:`RGBA` image with the given + dimensions. Dimensions can be specified either using a tuple :code:`(width, height)` + or by passing in :code:`width` and :code:`height` individually as positional + parameters. + + This makes use of pillow's :func:`pillow:PIL.Image.new` function, additional keyword + arguments passed to this function will be passed onto it. + + Parameters + ---------- + size: + The dimensions of the image, :code:`(width, height)` + mode: + The type of image to create, default :code:`RGBA`. See + :ref:`pillow:concept-modes` for more details. - See :func:`pillow:PIL.Image.new` """ - return Image(PImage.new(*args, **kwargs)) + + if isinstance(size, int): + if len(args) == 0: + raise ValueError("You must specify a width and a height") + + height, args = args[0], args[1:] + size = (size, height) + + return Image(PImage.new(mode, size, *args, **kwargs)) def fromarray(*args, **kwargs): @@ -150,7 +173,7 @@ def encode(image: Image) -> bytes: :: >>> import arlunio.image as image - >>> img = image.new("RGBA", (8, 8), color='red') + >>> img = image.new((8, 8), color='red') >>> image.encode(img) b'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFklEQVR4nGP8z8DwnwEPYMInOXwUAAASWwIOH0pJXQAAAABJRU5ErkJggg==' @@ -245,7 +268,6 @@ def colorramp(values, start: Optional[str] = None, stop: Optional[str] = None) - 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") @@ -328,7 +350,7 @@ def fill( background = "#0000" if background is None else background height, width = mask.shape - image = new("RGBA", (width, height), color=background) + image = new((width, height), color=background) else: image = image.copy() diff --git a/arlunio/shape.py b/arlunio/shape.py index 911d10aa..41420a2a 100644 --- a/arlunio/shape.py +++ b/arlunio/shape.py @@ -57,7 +57,7 @@ def Circle(x: math.X, y: math.Y, *, xc=0, yc=0, r=0.8, pt=None) -> mask.Mask: @ar.definition def Target(width: int, height: int) -> image.Image: - img = image.new("RGBA", (width, height), color="white") + img = image.new((width, height), color="white") parts = [ (shape.Circle(pt=0.02), "#000"), (shape.Circle(r=0.75, pt=0.12), "#f00"), @@ -96,7 +96,7 @@ def OlympicRings(width: int, height: int, *, spacing=0.5, pt=0.025): dx = spacing / 2 args = {"scale": 0.5, "r": spacing, "pt": pt} - img = image.new("RGBA", (width, height), color="white") + img = image.new((width, height), color="white") rings = [ (shape.Circle(yc=dy, xc=-(2.2 * dx), **args), "#0ff"), (shape.Circle(yc=dy, **args), "#000"), @@ -194,7 +194,7 @@ def Ellipse(x: math.X, y: math.Y, *, xc=0, yc=0, a=2, b=1, r=0.8, pt=None) -> ma @ar.definition def EllipseDemo(width: int, height: int): - img = image.new("RGBA", (width, height), color="white") + img = image.new(width, height, color="white") ellipses = [ shape.Ellipse(xc=-0.5, yc=-0.5, a=0.5, b=0.5, r=0.4), shape.Ellipse(yc=-0.5, a=1, b=0.5, r=0.4), @@ -340,7 +340,7 @@ def SuperEllipse( @ar.definition def SuperEllipseDemo(width: int, height: int): - img = image.new("RGBA", (width, height), color="white") + img = image.new(width, height, color="white") ellipses = [ (shape.SuperEllipse(n=0.5, pt=0.01),'#f00'), (shape.SuperEllipse(n=1, pt=0.01),'#0f0'), @@ -376,7 +376,7 @@ def SuperEllipseDemo(width: int, height: int): @ar.definition def Sauron(width: int, height: int): - img = image.new("RGBA", (width, height), color="white") + img = image.new(width, height, color="white") ellipses = [ (shape.SuperEllipse(a=2, n=3, m=0.2, r=0.98),'#f00'), (shape.SuperEllipse(n=2),'#f50'), diff --git a/blog/src/gallery/Rolling Hills.ipynb b/blog/src/gallery/Rolling Hills.ipynb deleted file mode 100644 index fe670281..00000000 --- a/blog/src/gallery/Rolling Hills.ipynb +++ /dev/null @@ -1,122 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import arlunio as ar\n", - "import numpy as np\n", - "\n", - "from arlunio.image import Image, fill\n", - "from arlunio.mask import any_\n", - "from arlunio.math import X, Y \n", - "from arlunio.shape import Circle" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "@ar.definition\n", - "def Cloud(x: X, y: Y, *, x0=0, y0=0, s=0.8):\n", - " r=s\n", - " \n", - " c1 = Circle(r=r)\n", - " c2 = Circle(xc=1, yc=-0.2, r=(r*0.8))\n", - " c3 = Circle(xc=0.6, yc=0.2, r=(r*0.8))\n", - " c4 = Circle(yc=0.5, r=(r*0.8))\n", - " \n", - " return any_(\n", - " c1(x=x, y=y),\n", - " c2(x=np.abs(x), y=y),\n", - " c3(x=np.abs(x), y=np.abs(y)),\n", - " c4(x=x, y=y)\n", - " )\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "@ar.definition\n", - "def RollingHills(width: int, height: int) -> Image:\n", - " \n", - " sun = Circle(xc=-1.2, yc=0.8, r=0.6)\n", - " image = fill(sun(width=width, height=height), foreground=\"#ff0\", background=\"#aef\")\n", - "\n", - " clouds = [\n", - " Cloud(x0=3.2, y0=2, scale=3),\n", - " Cloud(x0=1, y0=2, scale=5),\n", - " Cloud(x0=-2.7, y0=2, scale=3)\n", - " ]\n", - "\n", - " for cloud in clouds:\n", - " image = fill(cloud(width=width, height=height), foreground=\"#fff\", image=image)\n", - "\n", - " big_hill = Circle(x0=-1, y0=-2.1, r=1.5)\n", - " small_hill = Circle(x0=1, y0=-1.8, r=1.3)\n", - "\n", - " image = fill(big_hill(width=width, height=height), foreground=\"#0c0\", image=image)\n", - " image = fill(small_hill(width=width, height=height), foreground=\"#0e0\", image=image) \n", - "\n", - " return image " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "hills = RollingHills()\n", - "image = hills(width=1920, height=1080)\n", - "image" - ] - } - ], - "metadata": { - "arlunio": { - "author": { - "github": "alcarney", - "name": "Alex Carney" - }, - "version": "0.0.6" - }, - "kernelspec": { - "argv": [ - "python", - "-m", - "ipykernel_launcher", - "-f", - "{connection_file}" - ], - "display_name": "Python 3", - "env": null, - "interrupt_mode": "signal", - "language": "python", - "metadata": null, - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.2" - }, - "name": "Rolling Hills.ipynb" - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/users/chess/index.rst b/docs/users/chess/index.rst deleted file mode 100644 index d67b5890..00000000 --- a/docs/users/chess/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. _users_chess: - -Chess -===== - -.. nbtutorial:: - -.. toctree:: - - the-board diff --git a/docs/users/chess/the-board.rst b/docs/users/chess/the-board.rst deleted file mode 100644 index 751aa0eb..00000000 --- a/docs/users/chess/the-board.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. _users_chess_board: - -The Chess Board -=============== - -.. nbtutorial:: diff --git a/docs/users/getstarted/first-image.rst b/docs/users/getstarted/first-image.rst new file mode 100644 index 00000000..a384ed67 --- /dev/null +++ b/docs/users/getstarted/first-image.rst @@ -0,0 +1,130 @@ +Your First Image +================ + +.. arlunio-image:: Sunny Day + :align: center + :gallery: examples + + A nice sunny day:: + + import arlunio.image as image + import arlunio.shape as shape + + width, height = 1920, 1080 + img = image.new(width, height, color="lightskyblue") + + sun = shape.Circle(xc=-1.2, yc=0.8, r=0.6) + img += image.fill(sun(width=width, height=height), foreground="yellow") + + hill = shape.Circle(xc=-1, yc=-2.1, r=1.5) + img += image.fill(hill(width=width, height=height), foreground="limegreen") + + hill = shape.Circle(xc=1, yc=-1.8, r=1.3) + img += image.fill(hill(width=width, height=height), foreground="lawngreen") + + +In this tutorial we will be drawing the image you see above. Along the way +you'll learn some of ways you can create simple images and combine them into a +final composition. Given the visual nature of this library it's recommended +that you follow along using a Jupyter Notebook so that you can see the results +of each step incrementally. + +We start off by importing the modules we need. + +.. code-block:: python + + import arlunio.image as image + import arlunio.shape as shape + +Next we decide on a resolution at which we want to render our final image at +and create an image containing the background color representing the sky. + +.. code-block:: python + + width, height = 1920, 1080 + background = image.new(width, height, color="lightskyblue") + +Now we'll create a circle to represent the sun and position it over and up to +the left. To produce an image we can use the :code:`fill` function to color in +the circle with a yellow color. + +.. code-block:: python + + sun_shape = shape.Circle(xc=-1.2, yc=0.8, r=0.6) + sun = image.fill(sun_shape(width=width, height=height), foreground="yellow") + +Adding our :code:`background` and :code:`sun` images together we can start +building up our final image - order matters! + +.. code-block:: python + + final_image = background + sun + +Then following a similar process we can create the two hills and add them onto +the final image. + +.. code-block:: python + + hill = shape.Circle(xc=-1, yc=-2.1, r=1.5) + final_image += image.fill(hill(width=width, height=height), foreground="limegreen") + + hill = shape.Circle(xc=1, yc=-1.8, r=1.3) + final_image += image.fill(hill(width=width, height=height), foreground="lawngreen") + +Congratulations! You've just drawn your first image with :code:`arlunio`! Don't +forget to save it as a PNG so you can share it with all your friends 😃 + +.. code-block:: python + + final_image.save("sunny-day.png") + +Test Your Skills +---------------- + +As with anything practice makes perfect so we've included a few exercises for +you to try, can you use what you have learned so far to recreate the examples +below? + +We have included a solution for each example if you get stuck but keep in mind +that there is no "correct answer". Quite often there are multiple ways to +achieve the same result! + +.. arlunio-image:: Sunset + :align: center + :gallery: examples + + Sunset:: + + import arlunio.image as image + import arlunio.shape as shape + + w, h = 1920, 1080 + img = image.new(w, h, color="darkorange") + + sun = shape.Circle(xc=-1.2, yc=0, r=0.6) + img += image.fill(sun(width=w, height=h), foreground="yellow") + + hill = shape.Circle(xc=-1, yc=-2.1, r=1.5) + img += image.fill(hill(width=w, height=h), foreground="forestgreen") + + hill = shape.Circle(xc=1, yc=-1.8, r=1.3) + img += image.fill(hill(width=w, height=h), foreground="green") + +.. nbsolution:: + + .. code-block:: python + + import arlunio.image as image + import arlunio.shape as shape + + w, h = 1920, 1080 + img = image.new(w, h, color="darkorange") + + sun = shape.Circle(xc=-1.2, yc=0, r=0.6) + img += image.fill(sun(width=w, height=h), foreground="yellow") + + hill = shape.Circle(xc=-1, yc=-2.1, r=1.5) + img += image.fill(hill(width=w, height=h), foreground="forestgreen") + + hill = shape.Circle(xc=1, yc=-1.8, r=1.3) + img += image.fill(hill(width=w, height=h), foreground="green") \ No newline at end of file diff --git a/docs/users/getstarted/index.rst b/docs/users/getstarted/index.rst new file mode 100644 index 00000000..65129dce --- /dev/null +++ b/docs/users/getstarted/index.rst @@ -0,0 +1,9 @@ +.. _users_getstarted: + +Getting Started +=============== + +.. toctree:: + :caption: Index + + first-image diff --git a/docs/users/index.rst b/docs/users/index.rst index 2d84b1a6..d16cf27f 100644 --- a/docs/users/index.rst +++ b/docs/users/index.rst @@ -46,13 +46,12 @@ contained so feel free to try them in whichever order you fancy. The user guide is made up of the following sections -- |Chess|: Build a tool for visualising chess games. +- :ref:`users_getstarted`: New users should start here. .. toctree:: :hidden: - chess/index + getstarted/index -.. |Chess| replace:: :ref:`Chess ` -.. _Jupyter Lab: https://jupyterlab.readthedocs.io/en/stable/ \ No newline at end of file +.. _Jupyter Lab: https://jupyterlab.readthedocs.io/en/stable/ diff --git a/tests/test_image.py b/tests/test_image.py index 39506626..54f7a9d0 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,6 +1,7 @@ import unittest.mock as mock import numpy as np +import PIL.Image as PImage import py.test from hypothesis import given from hypothesis import settings @@ -14,12 +15,43 @@ def test_encode_decode(width, height): """Ensure that if we encode an image, then decode it we end up with the same thing.""" - expected = image.new("RGBA", (width, height), color="red") + expected = image.new((width, height), color="red") actual = image.decode(image.encode(expected)) assert expected == actual +@py.test.mark.parametrize( + "args, kwargs, pil_args, pil_kwargs", + [ + (((120, 120),), {}, ("RGBA", (120, 120)), {}), + ((120, 120), {}, ("RGBA", (120, 120)), {}), + (((120, 120),), {"mode": "L"}, ("L", (120, 120)), {}), + ((120, 120), {"mode": "L"}, ("L", (120, 120)), {}), + (((120, 120),), {"color": "red"}, ("RGBA", (120, 120)), {"color": "red"}), + ((120, 120), {"color": "red"}, ("RGBA", (120, 120)), {"color": "red"}), + ], +) +def test_new_image(args, kwargs, pil_args, pil_kwargs): + """Ensure that we wrap pillow's new image function correctly.""" + + expected = PImage.new(*pil_args, **pil_kwargs) + assert image.new(*args, **kwargs) == image.Image(expected) + + +@py.test.mark.parametrize( + "args, kwargs, message", [((120,), {}, "must specify a width and a height")] +) +def test_new_image_validation(args, kwargs, message): + """Ensure that the new image function performs some basic validation on its + input.""" + + with py.test.raises(ValueError) as err: + image.new(*args, **kwargs) + + assert message in str(err.value) + + class TestImage: """Tests for our image class""" @@ -29,7 +61,7 @@ def test_pil_method_passthrough(self, name): it correctly""" m_func = mock.MagicMock() - img = image.new("RGB", (4, 4), color="red") + img = image.new((4, 4), color="red", mode="RGB") setattr(img.img, name, m_func) getattr(img, name)() @@ -49,20 +81,29 @@ def test_pil_method_args_passthrough(self, name, args, kwargs): it correctly.""" m_func = mock.MagicMock() - img = image.new("RGB", (4, 4), color="red") + img = image.new((4, 4), color="red", mode="RGB") setattr(img.img, name, m_func) getattr(img, name)(*args, **kwargs) m_func.assert_called_with(*args, **kwargs) + @given(width=T.dimension, height=T.dimension) + def test_size_property(self, width, height): + """Ensure that we expose the size property correctly.""" + + p_img = PImage.new("RGB", (width, height)) + img = image.Image(p_img) + + assert img.size == p_img.size + @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.""" - a = image.new("RGBA", (width, height), color="black") - b = image.new("RGBA", (width // 2, height), color="red") + a = image.new((width, height), color="black") + b = image.new((width // 2, height), color="red") c = a + b assert c is not a, "Addition should return a new image object" @@ -151,7 +192,7 @@ def test_with_colors(self, fg, fgval, bg, bgval): 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") + img = image.new((2, 2), color="white", mode="RGB") mask = np.array([[True, True], [False, False]]) new_image = image.fill(mask, image=img) @@ -165,7 +206,7 @@ def test_fill_existing_rgb_image(self): 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") + img = image.new((2, 2), color="white") mask = np.array([[True, True], [False, False]]) new_image = image.fill(mask, image=img)