├── .gitignore ├── LICENSE ├── README.md ├── demo.py ├── docs ├── index.html ├── search.js ├── shades.html └── shades │ ├── canvas.html │ ├── noise_fields.html │ ├── shades.html │ └── utils.html ├── pyproject.toml ├── requirements-dev.lock ├── requirements.lock ├── shades ├── __init__.py ├── _wrappers.py ├── canvas.py ├── noise.py ├── shades.py └── utils.py └── tests ├── test_canvas.py ├── test_noise.py ├── test_shades.py ├── test_utils.py └── test_wrappers.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ben Rutter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shades 🕶️ 2 | 3 | The main branch for this is now for shades v2 which is still a work in progress (finding and fixing bugs). 4 | 5 | There's a bunch of bug fixing and optimisation to come, but I've overhauled the API now to: 6 | - use lazy evaluation to allow some efficiencies 7 | - shift a lot of the computation over into numpy for efficiency 8 | - improve the api with some nicer functional patterns around the canvas object 9 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | 3 | from shades import Canvas, block_color 4 | 5 | canvas = Canvas(1000, 1000, color=(242, 229, 212)) 6 | palette = [ 7 | block_color(i) 8 | for i in [ 9 | (222, 152, 189), 10 | (255, 255, 255), 11 | (91, 159, 204), 12 | (206, 90, 51), 13 | (245, 221, 51), 14 | ] 15 | ] 16 | 17 | # for x, y in canvas.grid(50): 18 | # color = block_color(choice(palette)) 19 | # canvas.rectangle_outline(color, (x, y), 40, 20, weight=2) 20 | 21 | canvas = canvas.for_grid(50).do( 22 | lambda c, p: c.rectangle_outline(choice(palette), p, 40, 20, weight=2) 23 | ) 24 | 25 | 26 | canvas.show() 27 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/shades.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | shades API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 42 |
43 |
44 |

45 | shades

46 | 47 |

Shades is a python module for generative 2d image creation.

48 | 49 |

Because of the relatively small level of classes and functions 50 | everything is imported into 'shades' name space to avoid 51 | long import commands for single items.

52 |
53 | 54 | 55 | 56 | 57 | 58 |
 1"""
 59 |  2Shades is a python module for generative 2d image creation.
 60 |  3
 61 |  4Because of the relatively small level of classes and functions
 62 |  5everything is imported into 'shades' name space to avoid
 63 |  6long import commands for single items.
 64 |  7"""
 65 |  8from .canvas import *
 66 |  9from .noise_fields import *
 67 | 10from .shades import *
 68 | 11from .utils import *
 69 | 
70 | 71 | 72 |
73 |
74 | 256 | -------------------------------------------------------------------------------- /docs/shades/utils.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | shades.utils API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 53 |
54 |
55 |

56 | shades.utils

57 | 58 |

utils

59 | 60 |

contains general purpose functions for use within or outside module

61 |
62 | 63 | 64 | 65 | 66 | 67 |
 1"""
 68 |  2utils
 69 |  3
 70 |  4contains general purpose functions for use within or outside module
 71 |  5"""
 72 |  6from typing import Tuple, Union
 73 |  7from random import randint
 74 |  8
 75 |  9
 76 | 10def color_clamp(color: Tuple[int, int, int]) -> Tuple[int, int, int]:
 77 | 11    """
 78 | 12    Ensures a three part iterable is a properly formatted color
 79 | 13    (i.e. all numbers between 0 and 255)
 80 | 14    """
 81 | 15    clamped_color = [max(min(int(i), 255), 0) for i in color]
 82 | 16    return tuple(clamped_color)
 83 | 17
 84 | 18
 85 | 19def distance_between_points(xy1: Tuple[int, int], xy2: Tuple[int, int]) -> float:
 86 | 20    """
 87 | 21    Returns the euclidean distance between two points.
 88 | 22    https://en.wikipedia.org/wiki/Euclidean_distance
 89 | 23    """
 90 | 24    return (((xy1[0] - xy2[0]) ** 2) + ((xy1[1] - xy2[1]) ** 2)) ** 0.5
 91 | 25
 92 | 26
 93 | 27def randomly_shift_point(
 94 | 28        xy_coords: Tuple[int, int],
 95 | 29        movement_range: Union[Tuple[int, int], Tuple[Tuple[int, int], Tuple[int, int]]],
 96 | 30    ) -> Tuple[int, int]:
 97 | 31    """
 98 | 32    Randomly shifts a point within defined range
 99 | 33
100 | 34    movement range of form:
101 | 35    (min amount, max amount)
102 | 36
103 | 37    you can give two movement ranges for:
104 | 38    [(min amount on x axis, max amount on x axis),
105 | 39    (min amount on y axis, max amount on y axis)]
106 | 40    or just one, if you want equal ranges
107 | 41    """
108 | 42    if type(movement_range[0]) not in [list, tuple]:
109 | 43        movement_range = [movement_range, movement_range]
110 | 44
111 | 45    shifted_xy = [
112 | 46        xy_coords[i] +
113 | 47        randint(movement_range[i][0], movement_range[i][1])
114 | 48        for i in range(2)
115 | 49    ]
116 | 50    return tuple(shifted_xy)
117 | 
118 | 119 | 120 |
121 |
122 | 123 |
124 | 125 | def 126 | color_clamp(color: Tuple[int, int, int]) -> Tuple[int, int, int]: 127 | 128 | 129 | 130 |
131 | 132 |
11def color_clamp(color: Tuple[int, int, int]) -> Tuple[int, int, int]:
133 | 12    """
134 | 13    Ensures a three part iterable is a properly formatted color
135 | 14    (i.e. all numbers between 0 and 255)
136 | 15    """
137 | 16    clamped_color = [max(min(int(i), 255), 0) for i in color]
138 | 17    return tuple(clamped_color)
139 | 
140 | 141 | 142 |

Ensures a three part iterable is a properly formatted color 143 | (i.e. all numbers between 0 and 255)

144 |
145 | 146 | 147 |
148 |
149 | 150 |
151 | 152 | def 153 | distance_between_points(xy1: Tuple[int, int], xy2: Tuple[int, int]) -> float: 154 | 155 | 156 | 157 |
158 | 159 |
20def distance_between_points(xy1: Tuple[int, int], xy2: Tuple[int, int]) -> float:
160 | 21    """
161 | 22    Returns the euclidean distance between two points.
162 | 23    https://en.wikipedia.org/wiki/Euclidean_distance
163 | 24    """
164 | 25    return (((xy1[0] - xy2[0]) ** 2) + ((xy1[1] - xy2[1]) ** 2)) ** 0.5
165 | 
166 | 167 | 168 |

Returns the euclidean distance between two points. 169 | https://en.wikipedia.org/wiki/Euclidean_distance

170 |
171 | 172 | 173 |
174 |
175 | 176 |
177 | 178 | def 179 | randomly_shift_point( xy_coords: Tuple[int, int], movement_range: Union[Tuple[int, int], Tuple[Tuple[int, int], Tuple[int, int]]]) -> Tuple[int, int]: 180 | 181 | 182 | 183 |
184 | 185 |
28def randomly_shift_point(
186 | 29        xy_coords: Tuple[int, int],
187 | 30        movement_range: Union[Tuple[int, int], Tuple[Tuple[int, int], Tuple[int, int]]],
188 | 31    ) -> Tuple[int, int]:
189 | 32    """
190 | 33    Randomly shifts a point within defined range
191 | 34
192 | 35    movement range of form:
193 | 36    (min amount, max amount)
194 | 37
195 | 38    you can give two movement ranges for:
196 | 39    [(min amount on x axis, max amount on x axis),
197 | 40    (min amount on y axis, max amount on y axis)]
198 | 41    or just one, if you want equal ranges
199 | 42    """
200 | 43    if type(movement_range[0]) not in [list, tuple]:
201 | 44        movement_range = [movement_range, movement_range]
202 | 45
203 | 46    shifted_xy = [
204 | 47        xy_coords[i] +
205 | 48        randint(movement_range[i][0], movement_range[i][1])
206 | 49        for i in range(2)
207 | 50    ]
208 | 51    return tuple(shifted_xy)
209 | 
210 | 211 | 212 |

Randomly shifts a point within defined range

213 | 214 |

movement range of form: 215 | (min amount, max amount)

216 | 217 |

you can give two movement ranges for: 218 | [(min amount on x axis, max amount on x axis), 219 | (min amount on y axis, max amount on y axis)] 220 | or just one, if you want equal ranges

221 |
222 | 223 | 224 |
225 |
226 | 408 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "shades" 7 | folder = "shades" 8 | readme = "README.md" 9 | requires-python = ">=3.9" 10 | description = "A python module for generative 2D Image creation" 11 | dynamic = ["version"] 12 | dependencies = [ 13 | "numpy>=1.20.0", 14 | "Pillow>=8.0.0", 15 | ] 16 | 17 | [tool.rye] 18 | dev-dependencies = [ 19 | "ipdb>=0.13.13", 20 | "pytest>=7.4.4", 21 | "mypy>=1.8.0", 22 | "twine>=4.0.2", 23 | "coverage>=7.4.0", 24 | "pdoc>=14.3.0", 25 | "ruff>=0.6.9", 26 | ] 27 | 28 | [tool.hatch.version] 29 | path = "shades/__init__.py" 30 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | 11 | -e file:. 12 | asttokens==2.4.1 13 | # via stack-data 14 | certifi==2023.11.17 15 | # via requests 16 | cffi==1.16.0 17 | # via cryptography 18 | charset-normalizer==3.3.2 19 | # via requests 20 | coverage==7.4.0 21 | cryptography==41.0.7 22 | # via secretstorage 23 | decorator==5.1.1 24 | # via ipdb 25 | # via ipython 26 | docutils==0.20.1 27 | # via readme-renderer 28 | exceptiongroup==1.2.0 29 | # via ipython 30 | # via pytest 31 | executing==2.0.1 32 | # via stack-data 33 | idna==3.6 34 | # via requests 35 | importlib-metadata==7.0.1 36 | # via keyring 37 | # via twine 38 | iniconfig==2.0.0 39 | # via pytest 40 | ipdb==0.13.13 41 | ipython==8.18.1 42 | # via ipdb 43 | jaraco-classes==3.3.0 44 | # via keyring 45 | jedi==0.19.1 46 | # via ipython 47 | jeepney==0.8.0 48 | # via keyring 49 | # via secretstorage 50 | jinja2==3.1.3 51 | # via pdoc 52 | keyring==24.3.0 53 | # via twine 54 | markdown-it-py==3.0.0 55 | # via rich 56 | markupsafe==2.1.4 57 | # via jinja2 58 | # via pdoc 59 | matplotlib-inline==0.1.6 60 | # via ipython 61 | mdurl==0.1.2 62 | # via markdown-it-py 63 | more-itertools==10.2.0 64 | # via jaraco-classes 65 | mypy==1.8.0 66 | mypy-extensions==1.0.0 67 | # via mypy 68 | nh3==0.2.15 69 | # via readme-renderer 70 | numpy==1.26.2 71 | # via shades 72 | packaging==23.2 73 | # via pytest 74 | parso==0.8.3 75 | # via jedi 76 | pdoc==14.4.0 77 | pexpect==4.9.0 78 | # via ipython 79 | pillow==10.1.0 80 | # via shades 81 | pkginfo==1.9.6 82 | # via twine 83 | pluggy==1.3.0 84 | # via pytest 85 | prompt-toolkit==3.0.43 86 | # via ipython 87 | ptyprocess==0.7.0 88 | # via pexpect 89 | pure-eval==0.2.2 90 | # via stack-data 91 | pycparser==2.21 92 | # via cffi 93 | pygments==2.17.2 94 | # via ipython 95 | # via pdoc 96 | # via readme-renderer 97 | # via rich 98 | pytest==7.4.4 99 | readme-renderer==42.0 100 | # via twine 101 | requests==2.31.0 102 | # via requests-toolbelt 103 | # via twine 104 | requests-toolbelt==1.0.0 105 | # via twine 106 | rfc3986==2.0.0 107 | # via twine 108 | rich==13.7.0 109 | # via twine 110 | ruff==0.6.9 111 | secretstorage==3.3.3 112 | # via keyring 113 | six==1.16.0 114 | # via asttokens 115 | stack-data==0.6.3 116 | # via ipython 117 | tomli==2.0.1 118 | # via ipdb 119 | # via mypy 120 | # via pytest 121 | traitlets==5.14.1 122 | # via ipython 123 | # via matplotlib-inline 124 | twine==4.0.2 125 | typing-extensions==4.9.0 126 | # via ipython 127 | # via mypy 128 | urllib3==2.1.0 129 | # via requests 130 | # via twine 131 | wcwidth==0.2.13 132 | # via prompt-toolkit 133 | zipp==3.17.0 134 | # via importlib-metadata 135 | zstandard==0.22.0 136 | # via hatch 137 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | 11 | 12 | -e file:. 13 | numpy==1.26.2 14 | # via shades 15 | pillow==10.1.0 16 | # via shades 17 | -------------------------------------------------------------------------------- /shades/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shades is a python module for generative 2d image creation. 3 | 4 | Because of the relatively small level of classes and functions 5 | everything is imported into 'shades' name space to avoid 6 | long import commands for single items. 7 | """ 8 | 9 | from shades.canvas import Canvas # noqa 10 | from shades.noise import noise_fields # noqa 11 | from shades.shades import block_color, gradient # noqa 12 | 13 | __version__ = "2.0.1a" 14 | -------------------------------------------------------------------------------- /shades/_wrappers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Function decorators for internal use. 3 | """ 4 | 5 | from typing import Callable, Tuple, Optional 6 | import inspect 7 | from functools import wraps 8 | 9 | 10 | def cast_ints(func: Callable) -> Callable: 11 | @wraps(func) 12 | def casted_func(*args, **kwargs): 13 | kwargs |= dict(zip(inspect.getfullargspec(func).args, args)) 14 | for kwarg, value in kwargs.items(): 15 | kwarg_type = func.__annotations__.get(kwarg) 16 | if kwarg_type == int: 17 | kwargs[kwarg] = round(value) 18 | elif kwarg_type == Optional[int] and value is not None: 19 | kwargs[kwarg] = round(value) 20 | elif kwarg_type == Tuple[int, int]: 21 | kwargs[kwarg] = (round(value[0]), round(value[1])) 22 | elif kwarg_type == Tuple[int, int, int]: 23 | kwargs[kwarg] = (round(value[0]), round(value[1]), round(value[2])) 24 | return func(**kwargs) 25 | 26 | return casted_func 27 | -------------------------------------------------------------------------------- /shades/canvas.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions/Classes relating to Shades' canvas object. 3 | 4 | The Canvas class is responsible for most of the actual "drawing" work in shades. 5 | I.e. it works out where a circle should go, and stores information on the 6 | color and shade etc. 7 | """ 8 | 9 | from typing import Callable, Tuple, List, Optional, Generator, DefaultDict 10 | from enum import Enum 11 | from collections import defaultdict 12 | 13 | from PIL import Image 14 | import numpy as np 15 | 16 | from shades.noise import NoiseField 17 | from shades._wrappers import cast_ints 18 | 19 | 20 | class GridIteratorCanvas: 21 | def __init__( 22 | self, canvas, x_size: int, y_size: Optional[int] = None, x_first: bool = True 23 | ) -> None: 24 | self.canvas = canvas 25 | self.x_size = x_size 26 | self.y_size = y_size 27 | self.x_first = x_first 28 | 29 | def do(self, apply: Callable) -> "Canvas": 30 | for x, y in self.canvas.grid(self.x_size, self.y_size, self.x_first): 31 | self.canvas = apply(self.canvas, (x, y)) 32 | return self.canvas 33 | 34 | 35 | class ColorMode(Enum): 36 | """ 37 | Enum for supported color modes. 38 | """ 39 | 40 | RGB = "RGB" 41 | HSV = "HSV" 42 | LAB = "LAB" 43 | 44 | 45 | class Canvas: 46 | """ 47 | Shades central Canvas object. 48 | 49 | Draws image on self (will help of a shade function) 50 | and contains methods for displaying / saving etc. 51 | """ 52 | 53 | def __init__( 54 | self, 55 | width: int = 700, 56 | height: int = 700, 57 | color: Tuple[int, ...] = (240, 240, 240), 58 | mode: ColorMode = ColorMode.RGB, 59 | ) -> None: 60 | """ 61 | Initialise a Canvas object 62 | """ 63 | self.mode = mode 64 | self.width: int = width 65 | self.height: int = height 66 | self.x_center: int = int(self.width / 2) 67 | self.y_center: int = int(self.height / 2) 68 | self.center: Tuple[int, int] = (self.x_center, self.y_center) 69 | self._image_array: np.ndarray = np.full( 70 | (height, width, 3), 71 | color, 72 | dtype=float, 73 | ) 74 | 75 | def image(self) -> Image: 76 | """ 77 | Return PIL image directly 78 | """ 79 | image = Image.fromarray( 80 | self._image_array.astype("uint8"), 81 | mode=self.mode.value, 82 | ) 83 | return image 84 | 85 | def show(self) -> None: 86 | """ 87 | Show image (using default image show). 88 | 89 | Renders internal image as PIL and shows using ```Image.show()``` method. 90 | See PIL documentation for more details. 91 | """ 92 | self.image().show() 93 | 94 | def save(self, path: str, format: Optional[str] = None, **kwargs) -> None: 95 | """ 96 | Save image to a given filepath. 97 | 98 | Any additional keyword arguments will be passed to image writer. 99 | """ 100 | self.image().save(path, format=format, **kwargs) 101 | 102 | def _add_to_image_array(self, array: np.array, shade: Callable) -> None: 103 | """ 104 | Calculates shades for the array (assumed 0 & 1 only values) and then draws onto 105 | the canvas. 106 | """ 107 | non_zeros = np.argwhere(array == 1) 108 | try: 109 | max_y = non_zeros[:, 0].max() 110 | min_y = non_zeros[:, 0].min() 111 | max_x = non_zeros[:, 1].max() 112 | min_x = non_zeros[:, 1].min() 113 | except ValueError: # we have no image to draw 114 | return 115 | 116 | shade_array = shade((min_x, min_y), max_x - min_x, max_y - min_y) 117 | shade_array = np.pad( 118 | shade_array, 119 | pad_width=( 120 | (min_y, self.height - max_y), 121 | (min_x, self.width - max_x), 122 | (0, 0), 123 | ), 124 | mode="constant", 125 | constant_values=0, 126 | ) 127 | shade_array *= np.repeat(array[:, :, np.newaxis], 3, axis=2) 128 | mask = np.any(shade_array != 0, axis=-1) 129 | self._image_array = np.where( 130 | mask[:, :, np.newaxis], shade_array, self._image_array 131 | ) 132 | 133 | def _shift_array_points( 134 | self, array: np.array, warp_noise: Tuple[NoiseField, NoiseField], shift: int 135 | ) -> np.ndarray: 136 | """ 137 | Move points in array based on x and y noise fields. 138 | 139 | Shift determines relation between a noise output of "1" 140 | and movement accross the canvas 141 | 142 | This is, at least currently, implemented as a very slow operation, 143 | iterating over all non-zero points, and moving them. It'll also 144 | potentially leave "gaps" - so use carefully, ideally on outlines 145 | of shapes rather than no the final shape. 146 | """ 147 | new_array: np.ndarray = np.zeros((self.height, self.width)) 148 | height, width = array.shape 149 | warp_noise = [i.noise_range((0, 0), height, width) for i in warp_noise] 150 | warp_noise = [(i - 0.5) * 2 * shift for i in warp_noise] 151 | y_noise, x_noise = warp_noise 152 | y_i, x_i = np.indices(array.shape) 153 | y_noise = y_noise.astype(int) + y_i 154 | x_noise = x_noise.astype(int) + x_i 155 | new_locs = np.stack((y_noise, x_noise)) 156 | to_move = np.argwhere(array == 1) 157 | for y, x in to_move: 158 | new_x = new_locs[0][y][x] 159 | new_y = new_locs[1][y][x] 160 | new_array[new_y][new_x] = 1 161 | return new_array 162 | 163 | def _points_in_line( 164 | self, start: Tuple[int, int], end: Tuple[int, int] 165 | ) -> Generator[Tuple[int, int], None, None]: 166 | """ 167 | Get the points in a line, iterating across the y axis 168 | """ 169 | if end[1] == start[1] and end[0] == start[0]: # point is 0 length 170 | yield end 171 | return 172 | if end[1] == start[1]: # point only moves over x axis 173 | x_dir = 1 if start[0] < end[0] else -1 174 | for x in range(start[0], end[0] + 1, x_dir): 175 | yield (x, start[1]) 176 | return 177 | if end[0] == start[0]: # point only moves over y axis 178 | y_dir = 1 if start[1] < end[1] else -1 179 | for y in range(start[1], end[1] + 1, y_dir): 180 | yield (start[0], y) 181 | return 182 | slope = (end[1] - start[1]) / (end[0] - start[0]) 183 | intercept = start[1] - slope * start[0] 184 | y_start = min(start[1], end[1]) 185 | y_end = max(start[1], end[1]) 186 | y_dir = 1 if y_start < y_end else -1 187 | for y in range(y_start, y_end + 1, y_dir): 188 | x = int(round(slope * y + intercept)) 189 | yield (x, y) 190 | 191 | def _rotate( 192 | self, array: np.ndarray, center: Tuple[int, int], degrees: int 193 | ) -> np.ndarray: 194 | """ 195 | Rotate array by degrees around center 196 | """ 197 | radians = np.radians(degrees) 198 | y_center, x_center = center 199 | y_size, x_size = array.shape 200 | i, j = np.ogrid[:y_size, :x_size] 201 | i_rotated = ( 202 | np.cos(radians) * (i - y_center) 203 | - np.sin(radians) * (j - x_center) 204 | + y_center 205 | ) 206 | j_rotated = ( 207 | np.sin(radians) * (i - y_center) 208 | + np.cos(radians) * (j - x_center) 209 | + x_center 210 | ) 211 | i_rotated = np.round(i_rotated).astype(int) 212 | j_rotated = np.round(j_rotated).astype(int) 213 | rotated_grid = np.zeros_like(array) 214 | mask = ( 215 | (i_rotated >= 0) 216 | & (i_rotated < y_size) 217 | & (j_rotated >= 0) 218 | & (j_rotated < x_size) 219 | ) 220 | rotated_grid[mask] = array[i_rotated[mask], j_rotated[mask]] 221 | 222 | return rotated_grid 223 | 224 | @cast_ints 225 | def for_grid( 226 | self, 227 | x_size: int, 228 | y_size: Optional[int] = None, 229 | x_first: bool = True, 230 | ) -> GridIteratorCanvas: 231 | """ 232 | Returns a 'GridIteratorCanvas' with a 'do' method for looping a given function over a grid. 233 | 234 | Allows method chaining rather if preferred over imperative programming. 235 | Also see `grid` method. 236 | 237 | Grid example: 238 | ```python 239 | for x, y in canvas.grid(10): 240 | canvas = canvas.square(red, (x, y), 30) 241 | ``` 242 | 243 | for_grid equivalent: 244 | ```python 245 | canvas = ( 246 | canvas 247 | .for_grid(10) 248 | .do(lambda canvas, point: canvas.square(red, point, 30)) 249 | ) 250 | ``` 251 | """ 252 | return GridIteratorCanvas(self, x_size, y_size, x_first) 253 | 254 | @cast_ints 255 | def grid( 256 | self, 257 | x_size: int, 258 | y_size: Optional[int] = None, 259 | x_first: bool = True, 260 | ) -> Generator[Tuple[int, int], None, None]: 261 | """ 262 | Generator to return x and y coordinates for a grid. Convenience to 263 | saves either double nested loops or lots of "list(range(canvas.width))" 264 | type expressions. 265 | 266 | If y_size is None, then will assume to be the same as x_size. 267 | 268 | Leave x_first as true to iterate first through xs, then ys, 269 | i.e. as if the loops was: 270 | ```python 271 | for x in xs: 272 | for y in ys: 273 | ... 274 | ``` 275 | otherwise, making False would be the equivalent of swapping those 276 | two lines around. 277 | 278 | Here's an example for use to print out the coordinates in a square grid: 279 | ```python 280 | for x, y in canvas.grid(10): 281 | print(x, y) 282 | ``` 283 | would print (0, 0), (0, 10), (0, 20) etc. . . 284 | 285 | Returned coordinates will always be (x, y) regardless of `x_first`. 286 | """ 287 | y_size = y_size or x_size 288 | first, second = ( 289 | (self.width, self.height) if x_first else (self.height, self.width) 290 | ) 291 | first_i, second_i = (x_size, y_size) if x_first else (y_size, x_size) 292 | for i in range(0, first + 1, first_i): 293 | for j in range(0, second + 1, second_i): 294 | if x_first: 295 | yield (i, j) 296 | else: 297 | yield (j, i) 298 | 299 | @cast_ints 300 | def rectangle( 301 | self, 302 | shade: Callable, 303 | corner: Tuple[int, int], 304 | width: int, 305 | height: int, 306 | ) -> "Canvas": 307 | """ 308 | Draw a rectangle on the canvas using the given shade. 309 | 310 | corner point corresponds to top left corner of the rectangle. 311 | """ 312 | x, y = corner 313 | array: np.ndarray = np.zeros((self.height, self.width)) 314 | array[y : y + height, x : x + width] = 1 315 | if rotation != 0: 316 | rotate_on = rotate_on or corner 317 | array = self._rotate(array, rotate_on, rotation) 318 | self._add_to_image_array(array, shade) 319 | return self 320 | 321 | @cast_ints 322 | def rectangle_outline( 323 | self, 324 | shade: Callable, 325 | corner: Tuple[int, int], 326 | width: int, 327 | height: int, 328 | weight: int = 1, 329 | ) -> "Canvas": 330 | """ 331 | Draw a rectangle outline on the canvas using the given shade. 332 | 333 | corner point corresponds to top left corner of the rectangle. 334 | """ 335 | x, y = corner 336 | self.line( 337 | shade, 338 | corner, 339 | (x, y + height), 340 | weight=weight, 341 | ) 342 | self.line( 343 | shade, 344 | corner, 345 | (x + width, y), 346 | weight=weight, 347 | ) 348 | self.line( 349 | shade, 350 | (x, y + height), 351 | (x + width, y + height), 352 | weight=weight, 353 | ) 354 | self.line( 355 | shade, 356 | (x + width, y), 357 | (x + width, y + height), 358 | weight=weight, 359 | ) 360 | return self 361 | 362 | @cast_ints 363 | def square( 364 | self, 365 | shade: Callable, 366 | corner: Tuple[int, int], 367 | width: int, 368 | ) -> "Canvas": 369 | """ 370 | Draw a square on the canvas using the given shade. 371 | 372 | corver point corresponts to the top left corner of the square. 373 | 374 | Size relates to the height or width (they are the same). 375 | """ 376 | return self.rectangle(shade, corner, width, width) 377 | 378 | @cast_ints 379 | def square_outline( 380 | self, 381 | shade: Callable, 382 | corner: Tuple[int, int], 383 | width: int, 384 | weight: int = 1, 385 | ) -> "Canvas": 386 | """ 387 | Draw a rectangle outline on the canvas using the given shade. 388 | 389 | corner point corresponds to top left corner of the rectangle. 390 | """ 391 | return self.rectangle_outline( 392 | shade=shade, 393 | corner=corner, 394 | width=width, 395 | height=width, 396 | weight=weight, 397 | ) 398 | 399 | @cast_ints 400 | def line( 401 | self, 402 | shade: Callable, 403 | start: Tuple[int, int], 404 | end: Tuple[int, int], 405 | weight: int = 1, 406 | ) -> "Canvas": 407 | """ 408 | Draw a line on the canvas using the given shade. 409 | """ 410 | array: np.ndarray = np.zeros((self.height, self.width)) 411 | for x, y in self._points_in_line(start, end): 412 | array[y : y + weight, x : x + weight] = 1 413 | if rotation != 0: 414 | rotate_on = rotate_on or start 415 | array = self._rotate(array, rotate_on, rotation) 416 | self._add_to_image_array(array, shade) 417 | return self 418 | 419 | @cast_ints 420 | def warped_line( 421 | self, 422 | shade: Callable, 423 | start: Tuple[int, int], 424 | end: Tuple[int, int], 425 | warp_noise: Tuple[NoiseField, NoiseField], 426 | shift: int, 427 | weight: int = 1, 428 | ) -> "Canvas": 429 | """ 430 | Draw a line, warped by noise fields, on the canvas using the 431 | given shade. 432 | """ 433 | array: np.ndarray = np.zeros((self.height, self.width)) 434 | for x, y in self._points_in_line(start, end): 435 | array[y, x : x + weight] = 1 436 | array = self._shift_array_points(array, warp_noise, shift) 437 | self._add_to_image_array(array, shade) 438 | return self 439 | 440 | def polygon( 441 | self, 442 | shade: Callable, 443 | *points: Tuple[int, int], 444 | ) -> "Canvas": 445 | """ 446 | Draw a polygon on canvas with the given shade. 447 | 448 | Uses ray tracing to determine points within shape, based on matching 449 | between first points, to second, to third (etc) to first. 450 | """ 451 | points = [(int(i[0]), int(i[1])) for i in points] # casting ints 452 | pairs = [ 453 | (point, points[(i + 1) % len(points)]) for i, point in enumerate(points) 454 | ] 455 | y_to_x_points: DefaultDict[int, List[int]] = defaultdict(lambda: []) 456 | for pair in pairs: 457 | for line_point in self._points_in_line(*pair): 458 | y_to_x_points[line_point[1]].append(line_point[0]) 459 | array: np.ndarray = np.zeros((self.height, self.width)) 460 | for y in y_to_x_points: 461 | xs = y_to_x_points[y] 462 | for start_x, end_x in zip(xs[::2], xs[1::2]): 463 | array[y, start_x:end_x] = 1 464 | if rotation != 0: 465 | rotate_on = rotate_on or points[0] 466 | array = self._rotate(array, rotate_on, rotation) 467 | self._add_to_image_array(array, shade) 468 | return self 469 | 470 | def warped_polygon( 471 | self, 472 | shade: Callable, 473 | *points: Tuple[int, int], 474 | warp_noise: Tuple[NoiseField, NoiseField], 475 | shift: int, 476 | rotation: int = 0, 477 | rotate_on: Optional[Tuple[int, int]] = None, 478 | ) -> "Canvas": 479 | """ 480 | Draw a polygon, warped by noise, on canvas with the given shade. 481 | 482 | Uses ray tracing to determine points within shape, based on matching 483 | between first points, to second, to third (etc) to first. 484 | """ 485 | # casting ints 486 | points = [int(i) for i in points] 487 | rotation = int(rotation) 488 | if rotate_on: 489 | rotate_on = (int(rotate_on[0]), int(rotate_on[1])) 490 | pairs = [ 491 | (point, points[(i + 1) % len(points)]) for i, point in enumerate(points) 492 | ] 493 | new_points: List[Tuple[int, int]] = [] 494 | for pair in pairs: 495 | for point in self._points_in_line(*pair): 496 | new_points.append(point) 497 | return self.polygon(shade, *new_points, warp_noise, shift, rotation, rotate_on) 498 | 499 | def polygon_outline( 500 | self, 501 | shade: Callable, 502 | *points: Tuple[int, int], 503 | weight: int = 1, 504 | ) -> "Canvas": 505 | """ 506 | Draw a polygon outline on canvas with the given shade. 507 | 508 | Uses ray tracing to determine points within shape, based on matching 509 | between first points, to second, to third (etc) to first. 510 | """ 511 | # casting ints 512 | points = [(int(i[0]), int(i[1])) for i in points] 513 | rotation = int(rotation) 514 | if rotate_on: 515 | rotate_on = (int(rotate_on[0]), int(rotate_on[1])) 516 | 517 | weight = int(weight) 518 | pairs = [ 519 | (point, points[(i + 1) % len(points)]) for i, point in enumerate(points) 520 | ] 521 | for point_one, point_two in pairs: 522 | self.line( 523 | shade, 524 | start=point_one, 525 | end=point_two, 526 | weight=weight, 527 | ) 528 | return self 529 | 530 | @cast_ints 531 | def triangle( 532 | self, 533 | shade: Callable, 534 | point_one: Tuple[int, int], 535 | point_two: Tuple[int, int], 536 | point_three: Tuple[int, int], 537 | ) -> "Canvas": 538 | return self.polygon( 539 | shade, 540 | point_one, 541 | point_two, 542 | point_three, 543 | ) 544 | 545 | @cast_ints 546 | def triangle_outline( 547 | self, 548 | shade: Callable, 549 | point_one: Tuple[int, int], 550 | point_two: Tuple[int, int], 551 | point_three: Tuple[int, int], 552 | rotation: int = 0, 553 | rotate_on: Optional[Tuple[int, int]] = None, 554 | weight: int = 1, 555 | ) -> "Canvas": 556 | return self.polyon_outline( 557 | shade, 558 | point_one, 559 | point_two, 560 | point_three, 561 | rotation=rotation, 562 | weight=weight, 563 | ) 564 | 565 | @cast_ints 566 | def warped_triangle_outline( 567 | self, 568 | shade: Callable, 569 | point_one: Tuple[int, int], 570 | point_two: Tuple[int, int], 571 | point_three: Tuple[int, int], 572 | warp_noise: Tuple[NoiseField, NoiseField], 573 | shift: int, 574 | rotation: int = 0, 575 | rotate_on: Optional[Tuple[int, int]] = None, 576 | weight: int = 1, 577 | ) -> "Canvas": 578 | return self.polygon_outline( 579 | shade, 580 | point_one, 581 | point_two, 582 | point_three, 583 | weight=weight, 584 | ) 585 | 586 | @cast_ints 587 | def circle( 588 | self, 589 | shade: Callable, 590 | center: Tuple[int, int], 591 | radius: int, 592 | ) -> "Canvas": 593 | """ 594 | Draw a circle on canvas with the given shade. 595 | """ 596 | x, y = center 597 | i, j = np.ogrid[: self.height, : self.width] 598 | array: np.ndarray = np.zeros((self.height, self.width)) 599 | array[(i - y) ** 2 + (j - x) ** 2 <= radius**2] = 1 600 | self._add_to_image_array(array, shade) 601 | return self 602 | 603 | def _circle_edge_points(self, center: Tuple[int, int], radius: int): 604 | circumference = radius * 2 605 | return [ 606 | ( 607 | center[0] + radius * np.cos(2 * np.pi * i / circumference), 608 | center[1] + radius * np.sin(2 * np.pi * i / circumference), 609 | ) 610 | for i in range(circumference) 611 | ] 612 | 613 | @cast_ints 614 | def warped_circle( 615 | self, 616 | shade: Callable, 617 | center: Tuple[int, int], 618 | radius: int, 619 | warp_noise: Tuple[NoiseField, NoiseField], 620 | shift: int, 621 | ) -> "Canvas": 622 | outline_points = self._circle_edge_points(center, radius) 623 | return self.warped_polygon( 624 | shade, *outline_points, warp_noise=warp_noise, shift=shift 625 | ) 626 | 627 | @cast_ints 628 | def circle_outline( 629 | self, 630 | shade: Callable, 631 | center: Tuple[int, int], 632 | radius: int, 633 | weight: int = 1, 634 | ) -> "Canvas": 635 | """ 636 | Draw a circle on canvas with the given shade. 637 | """ 638 | outline_points = self._circle_edge_points(center, radius) 639 | return self.polygon_outline(shade, *outline_points, weight=weight) 640 | 641 | @cast_ints 642 | def warped_circle_outline( 643 | self, 644 | shade: Callable, 645 | center: Tuple[int, int], 646 | radius: int, 647 | warp_noise: Tuple[NoiseField, NoiseField], 648 | shift: int, 649 | weight: int = 1, 650 | ) -> "Canvas": 651 | outline_points = self._circle_edge_points(center, radius) 652 | return self.warped_polygon_outline( 653 | shade, *outline_points, warp_noise=warp_noise, shift=shift, weight=weight 654 | ) 655 | -------------------------------------------------------------------------------- /shades/noise.py: -------------------------------------------------------------------------------- 1 | """ 2 | noise_fields 3 | 4 | Functions and classes relating to Shades' NoiseField 5 | """ 6 | 7 | import random 8 | import math 9 | 10 | from typing import List, Tuple, Union 11 | 12 | import numpy as np 13 | 14 | from numpy.typing import ArrayLike 15 | 16 | 17 | class NoiseField: 18 | """ 19 | An object to calculate and store perlin noise data. 20 | 21 | Initialisation takes float (recommend very low number < 0.1) 22 | and random seed 23 | """ 24 | 25 | def __init__(self, scale: float = 0.002, seed: int = None) -> None: 26 | if seed is None: 27 | self.seed = random.randint(0, 9999) 28 | else: 29 | self.seed = seed 30 | self.scale = scale 31 | size = 10 32 | self.x_lin = np.linspace(0, (size * self.scale), size, endpoint=False) 33 | self.y_lin = np.linspace(0, (size * self.scale), size, endpoint=False) 34 | self.field = self._perlin_field(self.x_lin, self.y_lin) 35 | self.x_negative_buffer = 0 36 | self.y_negative_buffer = 0 37 | self.buffer_chunks = 500 38 | 39 | def _roundup(self, to_round: float, nearest_n: float) -> float: 40 | """ 41 | Internal function to round up number to_round to nearest_n 42 | """ 43 | return int(math.ceil(to_round / nearest_n)) * nearest_n 44 | 45 | def _buffer_field_right(self, to_extend: int) -> None: 46 | """ 47 | Extends object's noise field right 48 | """ 49 | # y is just gonna stay the same, but x needs to be picking up 50 | max_lin = self.x_lin[-1] 51 | 52 | additional_x_lin = np.linspace( 53 | max_lin + self.scale, 54 | max_lin + (to_extend * self.scale), 55 | to_extend, 56 | endpoint=False, 57 | ) 58 | self.field = np.concatenate( 59 | [self.field, self._perlin_field(additional_x_lin, self.y_lin)], 60 | axis=1, 61 | ) 62 | self.x_lin = np.concatenate([self.x_lin, additional_x_lin]) 63 | 64 | def _buffer_field_bottom(self, to_extend: int) -> None: 65 | """ 66 | Extends object's noise field downwards 67 | """ 68 | max_lin = self.y_lin[-1] 69 | additional_y_lin = np.linspace( 70 | max_lin + self.scale, 71 | max_lin + (to_extend * self.scale), 72 | to_extend, 73 | endpoint=False, 74 | ) 75 | self.field = np.concatenate( 76 | [self.field, self._perlin_field(self.x_lin, additional_y_lin)], 77 | axis=0, 78 | ) 79 | self.y_lin = np.concatenate([self.y_lin, additional_y_lin]) 80 | 81 | def _buffer_field_left(self, to_extend: int) -> None: 82 | """ 83 | Extends object's noise field left 84 | """ 85 | min_lin = self.x_lin[0] 86 | additional_x_lin = np.linspace( 87 | min_lin - (to_extend * self.scale), 88 | min_lin, 89 | to_extend, 90 | endpoint=False, 91 | ) 92 | self.field = np.concatenate( 93 | [self._perlin_field(additional_x_lin, self.y_lin), self.field], 94 | axis=1, 95 | ) 96 | self.x_lin = np.concatenate([additional_x_lin, self.x_lin]) 97 | self.x_negative_buffer += to_extend 98 | 99 | def _buffer_field_top(self, to_extend: int) -> None: 100 | """ 101 | Extends object's noise field upwards 102 | """ 103 | min_lin = self.y_lin[0] 104 | additional_y_lin = np.linspace( 105 | min_lin - (to_extend * self.scale), 106 | min_lin, 107 | to_extend, 108 | endpoint=False, 109 | ) 110 | self.field = np.concatenate( 111 | [self._perlin_field(self.x_lin, additional_y_lin), self.field], 112 | axis=0, 113 | ) 114 | self.y_lin = np.concatenate([additional_y_lin, self.y_lin]) 115 | self.y_negative_buffer += to_extend 116 | 117 | def _perlin_field(self, x_lin: List[float], y_lin: List[float]) -> ArrayLike: 118 | """ 119 | generate field from x and y linear points 120 | 121 | credit to tgirod for stack overflow on numpy perlin noise (most of this code from answer) 122 | https://stackoverflow.com/questions/42147776/producing-2d-perlin-noise-with-numpy 123 | """ 124 | # remembering the random state (so we can put it back after) 125 | initial_random_state = np.random.get_state() 126 | x_grid, y_grid = np.meshgrid(x_lin, y_lin) 127 | x_grid %= 512 128 | y_grid %= 512 129 | # permutation table 130 | np.random.seed(self.seed) 131 | field_256 = np.arange(256, dtype=int) 132 | np.random.shuffle(field_256) 133 | field_256 = np.stack([field_256, field_256]).flatten() 134 | # coordinates of the top-left 135 | x_i, y_i = x_grid.astype(int), y_grid.astype(int) 136 | # internal coordinates 137 | x_f, y_f = x_grid - x_i, y_grid - y_i 138 | # fade factors 139 | u_array, v_array = self._fade(x_f), self._fade(y_f) 140 | # noise components 141 | n00 = self._gradient(field_256[(field_256[x_i % 512] + y_i) % 512], x_f, y_f) 142 | n01 = self._gradient( 143 | field_256[(field_256[x_i % 512] + y_i + 1) % 512], x_f, y_f - 1 144 | ) 145 | n11 = self._gradient( 146 | field_256[(field_256[((x_i % 512) + 1) % 512] + y_i + 1) % 512], 147 | x_f - 1, 148 | y_f - 1, 149 | ) 150 | n10 = self._gradient( 151 | field_256[(field_256[((x_i % 512) + 1) % 512] + y_i) % 512], x_f - 1, y_f 152 | ) 153 | # combine noises 154 | x_1 = self._lerp(n00, n10, u_array) 155 | x_2 = self._lerp(n01, n11, u_array) 156 | # putting the random state back in place 157 | np.random.set_state(initial_random_state) 158 | field = self._lerp(x_1, x_2, v_array) 159 | field += 0.5 160 | return field 161 | 162 | def _lerp( 163 | self, a_array: ArrayLike, b_array: ArrayLike, x_array: ArrayLike 164 | ) -> ArrayLike: 165 | "linear interpolation" 166 | return a_array + x_array * (b_array - a_array) 167 | 168 | def _fade(self, t_array: ArrayLike) -> ArrayLike: 169 | "6t^5 - 15t^4 + 10t^3" 170 | return 6 * t_array**5 - 15 * t_array**4 + 10 * t_array**3 171 | 172 | def _gradient( 173 | self, h_array: ArrayLike, x_array: ArrayLike, y_array: ArrayLike 174 | ) -> ArrayLike: 175 | "grad converts h to the right gradient vector and return the dot product with (x,y)" 176 | vectors = np.array([[0, 1], [0, -1], [1, 0], [-1, 0]]) 177 | g_array = vectors[h_array % 4] 178 | return g_array[:, :, 0] * x_array + g_array[:, :, 1] * y_array 179 | 180 | def _noise(self, xy_coords: Tuple[int, int]): 181 | """ 182 | Returns noise of xy coords 183 | Also manages noise_field (will dynamically recalcuate as needed) 184 | """ 185 | if self.scale == 0: 186 | return 0 187 | x_coord, y_coord = xy_coords 188 | x_coord += self.x_negative_buffer 189 | y_coord += self.y_negative_buffer 190 | if x_coord < 0: 191 | # x negative buffer needs to be increased 192 | x_to_backfill = self._roundup(abs(x_coord), self.buffer_chunks) 193 | self._buffer_field_left(x_to_backfill) 194 | x_coord, y_coord = xy_coords 195 | x_coord += self.x_negative_buffer 196 | y_coord += self.y_negative_buffer 197 | if y_coord < 0: 198 | # y negative buffer needs to be increased 199 | y_to_backfill = self._roundup(abs(y_coord), self.buffer_chunks) 200 | self._buffer_field_top(y_to_backfill) 201 | x_coord, y_coord = xy_coords 202 | x_coord += self.x_negative_buffer 203 | y_coord += self.y_negative_buffer 204 | try: 205 | return self.field[int(y_coord)][int(x_coord)] 206 | except IndexError: 207 | # ran out of generated noise, so need to extend the field 208 | height, width = self.field.shape 209 | x_to_extend = x_coord - width + 1 210 | y_to_extend = y_coord - height + 1 211 | if x_to_extend > 0: 212 | x_to_extend = self._roundup(x_to_extend, self.buffer_chunks) 213 | self._buffer_field_right(x_to_extend) 214 | if y_to_extend > 0: 215 | y_to_extend = self._roundup(y_to_extend, self.buffer_chunks) 216 | self._buffer_field_bottom(y_to_extend) 217 | return self._noise(xy_coords) 218 | 219 | def noise_range(self, xy: Tuple[int, int], width: int, height: int): 220 | """ 221 | Return noise values for a given grid (starting at point xy) 222 | and covering the stated width and height 223 | """ 224 | xy_coords = xy 225 | x_coord, y_coord = xy 226 | x_coord += width 227 | y_coord += height 228 | x_coord += self.x_negative_buffer 229 | y_coord += self.y_negative_buffer 230 | if x_coord < 0: 231 | # x negative buffer needs to be increased 232 | x_to_backfill = self._roundup(abs(x_coord), self.buffer_chunks) 233 | self._buffer_field_left(x_to_backfill) 234 | x_coord, y_coord = xy_coords 235 | x_coord += self.x_negative_buffer 236 | y_coord += self.y_negative_buffer 237 | if y_coord < 0: 238 | # y negative buffer needs to be increased 239 | y_to_backfill = self._roundup(abs(y_coord), self.buffer_chunks) 240 | self._buffer_field_top(y_to_backfill) 241 | x_coord, y_coord = xy_coords 242 | x_coord += self.x_negative_buffer 243 | y_coord += self.y_negative_buffer 244 | try: 245 | _ = self._noise((xy[0] + width, xy[1] + height)) 246 | return self.field[ 247 | int(xy[1]) : int(xy[1]) + height, int(xy[0]) : int(xy[0]) + width 248 | ] 249 | except IndexError: 250 | # ran out of generated noise, so need to extend the field 251 | height, width = self.field.shape 252 | x_to_extend = x_coord - width + 1 253 | y_to_extend = y_coord - height + 1 254 | if x_to_extend > 0: 255 | x_to_extend = self._roundup(x_to_extend, self.buffer_chunks) 256 | self._buffer_field_right(x_to_extend) 257 | if y_to_extend > 0: 258 | y_to_extend = self._roundup(y_to_extend, self.buffer_chunks) 259 | self._buffer_field_bottom(y_to_extend) 260 | return self.noise_range(xy, width, height) 261 | 262 | 263 | def noise_fields( 264 | scale: Union[List[float], float] = 0.002, 265 | seed: Union[List[int], int] = None, 266 | channels: int = 3, 267 | ) -> List[NoiseField]: 268 | """ 269 | Create multiple NoiseField objects in one go. 270 | This is a quality of life function, rather than adding new behaviour 271 | shades.noise_fields(scale=0.2, channels=3) rather than 272 | [shades.NoiseField(scale=0.2) for i in range(3)] 273 | """ 274 | 275 | if not isinstance(scale, list): 276 | scale = [scale for i in range(channels)] 277 | if not isinstance(seed, list): 278 | seed = [seed for i in range(channels)] 279 | 280 | return [NoiseField(scale=scale[i], seed=seed[i]) for i in range(channels)] 281 | -------------------------------------------------------------------------------- /shades/shades.py: -------------------------------------------------------------------------------- 1 | """ 2 | shades 3 | 4 | functions to be used for pixel level color generation with Canvas object 5 | """ 6 | 7 | from typing import Tuple, Callable 8 | from functools import cache 9 | 10 | import numpy as np 11 | 12 | from shades.noise import noise_fields, NoiseField 13 | 14 | 15 | def block_color(color: Tuple[int, int, int]) -> Callable: 16 | """ 17 | Creates a shade that shades everything with a block color 18 | """ 19 | 20 | def shade(xy: Tuple[int, int], width: int, height: int) -> np.ndarray: 21 | """ 22 | shade everything a single block color 23 | """ 24 | return np.full( 25 | (height, width, 3), 26 | color, 27 | dtype=float, 28 | ) 29 | 30 | return shade 31 | 32 | 33 | def gradient( 34 | color: Tuple[int, int, int] = (200, 200, 200), 35 | color_variance: int = 70, 36 | color_fields: Tuple[NoiseField, NoiseField, NoiseField] = noise_fields(channels=3), 37 | ) -> Callable: 38 | """ 39 | Creates a shade where colors vary based on noise fields 40 | 41 | color variance relate the amount a color will vary at the maximum 42 | noise point. color_variance of 100, means that noise will vary the 43 | tone of each channel (as in RGB) by up to 100. 44 | """ 45 | 46 | def shade(xy: Tuple[int, int], width: int, height: int) -> np.ndarray: 47 | """ 48 | shade varying based on noise fields 49 | """ 50 | noise_ranges = np.array( 51 | [i.noise_range(xy, width, height) for i in color_fields] 52 | ) 53 | noise_ranges = np.transpose(noise_ranges, (1, 2, 0)) 54 | noise_ranges -= 0.5 55 | noise_ranges *= color_variance * 2 56 | colors = np.full((height, width, 3), color, dtype=float) 57 | # TODO: clamp these colors to 0.1 - 255 range 58 | return colors + noise_ranges 59 | 60 | return shade 61 | 62 | 63 | def custom_shade( 64 | custom_function: Callable[Tuple[int, int], Tuple[int, int, int]], 65 | ) -> Callable: 66 | """ 67 | Convenience function to register a standard (x,y) coord to color function 68 | as a shade that can be used with the Canvas object. 69 | 70 | N.B. This support literally any python code, but means that 71 | the code itself won't be vectorized, so involves an inner loop 72 | that will slow things down a lot. 73 | 74 | Where possible, simply writing a shade to return an np.array will 75 | provide a big speed-up over this method. 76 | """ 77 | 78 | def shade(xy: Tuple[int, int], width: int, height: int) -> np.ndarray: 79 | """ 80 | Custom defined shade 81 | """ 82 | colors = [] 83 | for x in range(xy[0], xy[0] + height): 84 | row = [] 85 | for y in range(xy[1], xy[1] + width): 86 | row.append(custom_function((x, y))) 87 | colors.append(row) 88 | return np.array(colors) 89 | 90 | return shade 91 | -------------------------------------------------------------------------------- /shades/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | General handy function for drawing 3 | """ 4 | 5 | from typing import Tuple, Union 6 | from random import randint 7 | 8 | 9 | def euclidean_distance(point_one: Tuple[int, int], point_two: Tuple[int, int]) -> float: 10 | """ 11 | Returns the 12 | (euclidean distance)[https://en.wikipedia.org/wiki/Euclidean_distance] 13 | between two points. 14 | """ 15 | return ( 16 | ((point_one[0] - point_two[0]) ** 2) + ((point_one[1] - point_two[1]) ** 2) 17 | ) ** 0.5 18 | 19 | 20 | def randomly_shift_point( 21 | xy_coords: Tuple[int, int], 22 | movement_range: Union[Tuple[int, int], Tuple[Tuple[int, int], Tuple[int, int]]], 23 | ) -> Tuple[int, int]: 24 | """ 25 | Randomly shifts a point within defined range 26 | 27 | movement range of form: 28 | (min amount, max amount) 29 | 30 | you can give two movement ranges for: 31 | [(min amount on x axis, max amount on x axis), 32 | (min amount on y axis, max amount on y axis)] 33 | or just one, if you want equal ranges 34 | """ 35 | if type(movement_range[0]) not in [list, tuple]: 36 | movement_range = [movement_range, movement_range] 37 | 38 | shifted_xy = [ 39 | xy_coords[i] + randint(movement_range[i][0], movement_range[i][1]) 40 | for i in range(2) 41 | ] 42 | return tuple(shifted_xy) 43 | -------------------------------------------------------------------------------- /tests/test_canvas.py: -------------------------------------------------------------------------------- 1 | """ 2 | tests for the shades.canvas module 3 | """ 4 | import numpy as np 5 | import pytest 6 | 7 | from shades import canvas 8 | from shades.shades import block_color 9 | 10 | 11 | @pytest.fixture 12 | def canvas_obj(): 13 | return canvas.Canvas(10, 10, (0, 0, 255)) 14 | 15 | 16 | @pytest.fixture 17 | def small_canvas(): 18 | return canvas.Canvas(3, 3, (0, 0, 255)) 19 | 20 | 21 | @pytest.fixture 22 | def black(): 23 | return block_color((0, 0, 0)) 24 | 25 | 26 | def test_image_returns_expected_pil_image(canvas_obj): 27 | actual = canvas_obj.image().getpixel((4, 4)) 28 | assert actual == (0, 0, 255) 29 | 30 | 31 | def test_grid_provides_correctly_spaces_xy_coords(canvas_obj): 32 | actual = {i for i in canvas_obj.grid(10, 10)} 33 | assert actual == {(0, 0), (0, 10), (10, 10), (10, 0)} 34 | 35 | 36 | def test_rectangle_draws_expected_shape(small_canvas, black): 37 | actual = small_canvas.rectangle(black, (1, 1), 2, 1)._stack[0][1] 38 | assert (actual == np.array([[0, 0, 0], [0, 1, 1], [0, 0, 0]])).all() 39 | 40 | 41 | def test_rectangle_outline_draws_expected_shape(small_canvas, black): 42 | actual = small_canvas.rectangle_outline(black, (1, 1), 2, 1)._stack[0][1] 43 | assert (actual == np.array([[0, 0, 0], [0, 1, 0], [0, 1, 0]])).all() 44 | 45 | 46 | def test_square_draws_expected_shape(small_canvas, black): 47 | actual = small_canvas.square(black, (1, 1), 2)._stack[0][1] 48 | assert (actual == np.array([[0, 0, 0], [0, 1, 1], [0, 1, 1]])).all() 49 | 50 | 51 | def test_square_outline_draws_expected_shape(small_canvas, black): 52 | small_canvas.square_outline(black, (1, 1), 2)._compress_stack() 53 | actual = small_canvas._stack[0][1] 54 | assert (actual == np.array([[0, 0, 0], [0, 1, 1], [0, 1, 0]])).all() 55 | 56 | 57 | def test_line_draws_expected_shape(small_canvas, black): 58 | small_canvas.line(black, (1, 0), (0, 1))._compress_stack() 59 | actual = small_canvas._stack[0][1] 60 | assert (actual == np.array([[0, 1, 0], [1, 0, 0], [0, 0, 0]])).all() 61 | 62 | 63 | def test_polygon_draws_expected_shape(small_canvas, black): 64 | small_canvas.polygon(black, (1, 1), (3, 2), (2, 2))._compress_stack() 65 | actual = small_canvas._stack[0][1] 66 | assert (actual == np.array([[0, 0, 0], [0, 1, 0], [0, 0, 1]])).all() 67 | 68 | 69 | def test_polygon_outline_draws_expected_shape(small_canvas, black): 70 | actual = small_canvas.polygon_outline(black, (1, 1), (2, 1))._stack[0][1] 71 | assert (actual == np.array([[0, 0, 0], [0, 1, 1], [0, 0, 0]])).all() 72 | 73 | 74 | def test_triangle_draws_expected_shape(small_canvas, black): 75 | actual = small_canvas.triangle(black, (0, 0), (1, 0), (2, 2))._stack[0][1] 76 | assert (actual == np.array([[1, 1, 0], [1, 1, 0], [0, 0, 1]])).all() 77 | 78 | 79 | def test_triangle_outline_draws_expected_shape(small_canvas, black): 80 | actual = small_canvas.triangle_outline(black, (0, 0), (1, 0), (2, 2))._stack[0][1] 81 | assert (actual == np.array([[1, 1, 0], [0, 0, 0], [0, 0, 0]])).all() 82 | 83 | 84 | def test_circle_draws_expected_shape(small_canvas, black): 85 | actual = small_canvas.circle(black, (1, 1), 1)._stack[0][1] 86 | assert (actual == np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]])).all() 87 | 88 | 89 | def test_circle_outline_draws_expected_shape(small_canvas, black): 90 | actual = small_canvas.circle_outline(black, (1, 1), 2)._stack[0][1] 91 | assert (actual == np.array([[0, 0, 0], [0, 0, 0], [0, 0, 1]])).all() 92 | -------------------------------------------------------------------------------- /tests/test_noise.py: -------------------------------------------------------------------------------- 1 | """ 2 | tests for shades.noise module 3 | """ 4 | from shades import noise 5 | 6 | import pytest 7 | 8 | @pytest.fixture 9 | def field(): 10 | return noise.NoiseField() 11 | 12 | def test_noise_range_returns_expected_size_object(field): 13 | actual = field.noise_range((0, 0), 10, 20) 14 | assert actual.shape == (20, 10) 15 | 16 | def test_noise_range_is_different_based_on_start_point(field): 17 | actual = field.noise_range((0, 0), 10, 20) 18 | also_actual = field.noise_range((10, 10), 10, 20) 19 | assert not (actual == also_actual).all() 20 | 21 | def test_noise_fields_function_returns_list_of_requested_noise_fields(): 22 | actual = noise.noise_fields(0.002, 2, 4) 23 | assert len(actual) == 4 24 | 25 | def test_noise_fields_can_take_list_of_scale(): 26 | actual = noise.noise_fields([1, 2, 3], channels=3) 27 | assert {i.scale for i in actual} == {1, 2, 3} 28 | 29 | def test_noise_fields_can_take_list_of_seeds(): 30 | actual = noise.noise_fields(seed=[1, 2, 3], channels=3) 31 | assert {i.seed for i in actual} == {1, 2, 3} 32 | 33 | -------------------------------------------------------------------------------- /tests/test_shades.py: -------------------------------------------------------------------------------- 1 | """ 2 | tests for shades.shades module 3 | """ 4 | from shades import shades 5 | 6 | def test_block_color_returns_array_of_identical_colors(): 7 | color = shades.block_color((200, 10, 130)) 8 | actual = color((0, 0), 10, 10) 9 | assert (actual == (200, 10, 130)).all() 10 | 11 | def test_gradient_produces_expected_shade(): 12 | gradient = shades.gradient() 13 | actual = gradient((20, 40), 2, 4) 14 | assert actual.shape == (4, 2, 3) 15 | 16 | def test_custom_shade_allows_any_python_function_over_xy_coords(): 17 | custom = shades.custom_shade(lambda xy: (2, 2, 4)) 18 | actual = custom((0, 0), 4, 4) 19 | assert (actual == (2, 2, 4)).all() 20 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | tests for shades.utils module 3 | """ 4 | from shades import utils 5 | 6 | def test_euclidean_distance_returns_expected_values(): 7 | actual = utils.euclidean_distance((-32, 10), (31, 34)) 8 | assert 67.4166 < actual < 67.4167 9 | 10 | def test_randomly_shift_point_produces_new_point(): 11 | points = [(3, 4), (-3, -25), (34, -24444)] 12 | actual = [utils.randomly_shift_point(i, (10, 10)) for i in points] 13 | assert not all([i==j for i, j in zip(points, actual)]) 14 | -------------------------------------------------------------------------------- /tests/test_wrappers.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | from shades import _wrappers 3 | 4 | def test_cast_int_converts_floats_based_on_type_hints(): 5 | @_wrappers.cast_ints 6 | def some_function(a: int): 7 | assert isinstance(a, int) 8 | some_function(1.1) 9 | 10 | def test_cast_int_leaves_not_int_hinted_things_alone(): 11 | @_wrappers.cast_ints 12 | def some_function(a: float, b): 13 | return (a, b) 14 | actual, also_actual = some_function(1.2, "cool") 15 | assert not isinstance(actual, int) 16 | assert not isinstance(also_actual, int) 17 | 18 | def test_case_int_works_also_on_tuple_of_ints_of_two_or_three_length(): 19 | @_wrappers.cast_ints 20 | def some_function(a: Tuple[int, int]): 21 | one, two = a 22 | assert isinstance(one, int) 23 | assert isinstance(two, int) 24 | some_function((3.4, 3.1)) 25 | @_wrappers.cast_ints 26 | def some_function(a: Tuple[int, int, int]): 27 | one, two, three = a 28 | assert isinstance(one, int) 29 | assert isinstance(two, int) 30 | assert isinstance(three, int) 31 | some_function((3.4, 3.1, 1.1)) 32 | --------------------------------------------------------------------------------