├── .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 |
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.canvasimport*
66 | 9from.noise_fieldsimport*
67 | 10from.shadesimport*
68 | 11from.utilsimport*
69 |
11defcolor_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 | 16clamped_color=[max(min(int(i),255),0)foriincolor]
138 | 17returntuple(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)
28defrandomly_shift_point(
186 | 29xy_coords:Tuple[int,int],
187 | 30movement_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 | 43iftype(movement_range[0])notin[list,tuple]:
201 | 44movement_range=[movement_range,movement_range]
202 | 45
203 | 46shifted_xy=[
204 | 47xy_coords[i]+
205 | 48randint(movement_range[i][0],movement_range[i][1])
206 | 49foriinrange(2)
207 | 50]
208 | 51returntuple(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 |
--------------------------------------------------------------------------------