├── .gitignore ├── images ├── example1.png └── example2.gif ├── typedefs.py ├── README.md ├── perlin_lattice.py └── tile_generator.py /.gitignore: -------------------------------------------------------------------------------- 1 | /__pycache__/ 2 | -------------------------------------------------------------------------------- /images/example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gollum999/perlin-wang/HEAD/images/example1.png -------------------------------------------------------------------------------- /images/example2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gollum999/perlin-wang/HEAD/images/example2.gif -------------------------------------------------------------------------------- /typedefs.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, NamedTuple, TypeVar 2 | 3 | 4 | T = TypeVar('T') 5 | 6 | class Rect(NamedTuple, Generic[T]): 7 | """ Holds four things, one for each side of a rectangle. """ 8 | top: T 9 | right: T 10 | bottom: T 11 | left: T 12 | 13 | 14 | ColorIdx = int 15 | ColorEdge = list[int] # a shuffled list of indices for looking up gradients on the edges of tiles 16 | 17 | EdgeColors = Rect[ColorIdx] 18 | ColorEdges = Rect[ColorEdge] 19 | ColorToEdgeDict = dict[ColorIdx, ColorEdge] 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Perlin Noise Wang Tile Generator 2 | This script combines the concepts of [Perlin Noise](https://en.wikipedia.org/wiki/Perlin_noise) and [Wang Tiles](https://en.wikipedia.org/wiki/Wang_tile) to generate a set of non-periodically tileable noise images. 3 | 4 | Based on the work of [David Maung](https://etd.ohiolink.edu/apexprod/rws_etd/send_file/send?accession=osu1461077485&disposition=inline), [Michael Cohen, Jonathan Shade, Stefan Hiller, and Oliver Deussen](https://www.researchgate.net/publication/2864579_Wang_Tiles_for_Image_and_Texture_Generation). 5 | 6 | ## Examples 7 | ![Tileable Perlin noise](/images/example1.png) 8 | ![With colorized edges](/images/example2.gif) 9 | 10 | ## Usage 11 | ``` 12 | usage: tile_generator.py [-h] [--output OUTPUT] [--output-name OUTPUT_NAME] [--output-format OUTPUT_FORMAT] [--seed SEED] --tile-size TILE_SIZE [--frequency FREQUENCY] [--period PERIOD] 13 | [--amplitude AMPLITUDE] [--octaves OCTAVES] [--x-colors X_COLORS] [--y-colors Y_COLORS] [--n-choices N_CHOICES] [--colorize-edges] 14 | 15 | options: 16 | -h, --help show this help message and exit 17 | --output OUTPUT Path to output directory 18 | --output-name OUTPUT_NAME 19 | Name to prepend to all output files. Default = tile 20 | --output-format OUTPUT_FORMAT 21 | Output file type. Default = png 22 | --seed SEED Seed for random number generation. Default = random seed 23 | 24 | Perlin noise settings: 25 | --tile-size TILE_SIZE 26 | Size of tiles (length of one side in pixels, must be a power of 2) 27 | --frequency FREQUENCY 28 | Frequency of Perlin lattice points in pixels, overrides --period. Default = None 29 | --period PERIOD Number of pixels between Perlin lattice points; inverse of frequency. Default = 8 30 | --amplitude AMPLITUDE 31 | Delta between min and max value. Default = 1.0 32 | --octaves OCTAVES Number of noise waves to combine; a higher value results in more detail. Default = 1 33 | 34 | Wang tile settings: 35 | Total number of tiles will be x_colors * y_colors * n_choices. 36 | 37 | --x-colors X_COLORS Number of colors to use when tiling left-right. Default = 2 38 | --y-colors Y_COLORS Number of colors to use when tiling up-down. Default = 2 39 | --n-choices N_CHOICES 40 | Number of alternative tile options for each unique pair of tile up-left colors. Default = 2 41 | --colorize-edges Colorize edges in output for easier visual matching. Desaturate to restore original images. 42 | 43 | ``` 44 | 45 | ## Dependencies 46 | * [more_itertools](https://more-itertools.readthedocs.io/en/stable/) 47 | * [numpy](https://numpy.org/doc/stable/index.html) 48 | * [Pillow (PIL)](https://pillow.readthedocs.io/en/stable/index.html) 49 | * [vectormath](https://pypi.org/project/vectormath/) 50 | 51 | -------------------------------------------------------------------------------- /perlin_lattice.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | from typing import Self 4 | 5 | import numpy as np 6 | from vectormath import Vector2 7 | 8 | from typedefs import ColorEdges, ColorToEdgeDict, EdgeColors, Rect 9 | 10 | 11 | def _make_gradients(count: int): 12 | """ Build a list of gradient vectors for Perlin noise. """ 13 | return [Vector2(math.cos(arc_idx * 2.0 * math.pi / count), 14 | math.sin(arc_idx * 2.0 * math.pi / count)) 15 | for arc_idx in range(count)] 16 | 17 | 18 | class PerlinLattice: 19 | """ 20 | This represents the "lattice" of gradients that ultimately gives Perlin noise its random appearance. 21 | Edge and corner gradients are overridden to enable seamless tiling. 22 | """ 23 | 24 | GRADIENT_COUNT = 256 25 | GRADIENTS = _make_gradients(GRADIENT_COUNT) 26 | 27 | def __init__(self, color_to_edge: ColorToEdgeDict, color_indices: EdgeColors): 28 | self.center_idxs = self._make_center_permutations() 29 | self.edge_idxs = self._make_edge_permutations(color_to_edge, color_indices) 30 | # all corners must share the same gradient index since the corner points are shared between edges 31 | self.corner_idx = color_to_edge[0][0] 32 | 33 | def _make_center_permutations(self) -> list[int]: 34 | """ Build the primary "permutation set" used to choose pseudo-random gradients at each lattice point. """ 35 | center_permutations = list(range(self.GRADIENT_COUNT)) 36 | random.shuffle(center_permutations) 37 | center_permutations += center_permutations 38 | return center_permutations 39 | 40 | def _make_edge_permutations(self, color_to_edge: ColorToEdgeDict, color_indices: EdgeColors) -> ColorEdges: 41 | """ Build the "permutation sets" that override all edge points of the lattice. """ 42 | return ColorEdges( 43 | top=color_to_edge[color_indices.top], 44 | right=color_to_edge[color_indices.right], 45 | bottom=color_to_edge[color_indices.bottom], 46 | left=color_to_edge[color_indices.left], 47 | ) 48 | 49 | def get_all_corners(self, lattice_size: int) -> list[Vector2]: 50 | """ Return the coordinates that represent the corners of this lattice. """ 51 | return [ 52 | Vector2(0, 0), 53 | Vector2(0, lattice_size - 1), 54 | Vector2(lattice_size - 1, 0), 55 | Vector2(lattice_size - 1, lattice_size - 1), 56 | ] 57 | 58 | def get_gradient_vector(self, lattice_point: Vector2, lattice_size: int) -> Vector2: 59 | """ Get the gradient at the specified lattice point. """ 60 | edges = Rect(top=0, left=0, right=lattice_size - 1, bottom=lattice_size - 1) 61 | match tuple(lattice_point.astype(int)): 62 | # corners 63 | case (edges.left, edges.top) | (edges.right, edges.top) | (edges.left, edges.bottom) | (edges.right, edges.bottom): 64 | lattice_point_hash = self.corner_idx 65 | 66 | # edges 67 | case (edges.left, y): 68 | lattice_point_hash = self.edge_idxs.left[y] 69 | case (edges.right, y): 70 | lattice_point_hash = self.edge_idxs.right[y] 71 | case (x, edges.top): 72 | lattice_point_hash = self.edge_idxs.top[x] 73 | case (x, edges.bottom): 74 | lattice_point_hash = self.edge_idxs.bottom[x] 75 | 76 | # center 77 | case (x, y): 78 | lattice_point_hash = self.center_idxs[self.center_idxs[x % self.GRADIENT_COUNT] + y % self.GRADIENT_COUNT] 79 | 80 | return self.GRADIENTS[lattice_point_hash] 81 | 82 | def gradient(self, point: Vector2, lattice_point: Vector2, lattice_size: int) -> float: 83 | """ Calculate the partial noise value at point based on the gradient vector of the specified lattice point. """ 84 | assert list(lattice_point) == list(np.floor(lattice_point)) # corner always falls on int coords 85 | delta_from_corner = point - lattice_point 86 | corner_gradient = self.get_gradient_vector(lattice_point, lattice_size) 87 | return (self.smooth_ramp(abs(delta_from_corner.x)) 88 | * self.smooth_ramp(abs(delta_from_corner.y)) 89 | * delta_from_corner.dot(corner_gradient)) 90 | 91 | @staticmethod 92 | def smooth_ramp(t: float) -> float: 93 | """ Quintic polynomial smoothing. Called 'fade' in the reference implementation. """ 94 | assert 0.0 <= t <= 1.0 95 | return 1 - (6 * t**5) + (15 * t**4) - (10 * t**3) 96 | -------------------------------------------------------------------------------- /tile_generator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import colorsys 4 | import itertools 5 | import logging 6 | import random 7 | import sys 8 | from pathlib import Path 9 | from typing import Iterable, NamedTuple, TypeVar 10 | 11 | import more_itertools 12 | import numpy as np 13 | from PIL import Image, ImageDraw 14 | from vectormath import Vector2 15 | 16 | from perlin_lattice import PerlinLattice 17 | from typedefs import ColorEdge, ColorToEdgeDict, EdgeColors 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class PerlinArgs(NamedTuple): 24 | """ Bundle of args for controlling Perlin noise. """ 25 | tile_size: int 26 | frequency: float 27 | amplitude: float 28 | octaves: int 29 | 30 | def lattice_size(self, octave: int) -> int: 31 | """ Return the edge length of the Perlin lattice that is required to calculate noise at the specified octave. """ 32 | size = int(self.tile_size * self.frequency * 2**octave) + 1 33 | assert is_power_of_two(size - 1) 34 | return size 35 | 36 | 37 | class WangArgs(NamedTuple): 38 | """ Bundle of args for controlling Wang tiles. """ 39 | x_colors: int 40 | y_colors: int 41 | n_choices: int 42 | colorize_edges: bool 43 | 44 | 45 | def is_power_of_two(n: int) -> bool: 46 | """ Return True if `n` is a power of 2. """ 47 | return (n != 0) and (n & (n-1) == 0) 48 | 49 | 50 | def parse_args() -> argparse.Namespace: 51 | """ Parse command-line arguments and validate values. """ 52 | parser = argparse.ArgumentParser() 53 | parser.add_argument('--output', type=Path, help='Path to output directory') 54 | parser.add_argument('--output-name', type=str, default='tile', help='Name to prepend to all output files. Default = %(default)s') 55 | parser.add_argument('--output-format', type=str, default='png', help='Output file type. Default = %(default)s') 56 | parser.add_argument('--seed', type=int, help='Seed for random number generation. Default = random seed') 57 | 58 | perlin_group = parser.add_argument_group('Perlin noise settings') 59 | perlin_group.add_argument('--tile-size', type=int, required=True, 60 | help='Size of tiles (length of one side in pixels, must be a power of 2)') 61 | perlin_group.add_argument('--frequency', type=float, 62 | help='Frequency of Perlin lattice points in pixels, overrides --period. Default = %(default)s') 63 | perlin_group.add_argument('--period', type=int, default=8, 64 | help='Number of pixels between Perlin lattice points; inverse of frequency. Default = %(default)s') 65 | perlin_group.add_argument('--amplitude', type=float, default=1.0, 66 | help='Delta between min and max value. Default = %(default)s') 67 | perlin_group.add_argument('--octaves', type=int, default=1, 68 | help='Number of noise waves to combine; a higher value results in more detail. Default = %(default)s') 69 | 70 | wang_group = parser.add_argument_group('Wang tile settings', 71 | description='Total number of tiles will be x_colors * y_colors * n_choices.') 72 | wang_group.add_argument('--x-colors', type=int, default=2, 73 | help='Number of colors to use when tiling left-right. Default = %(default)s') 74 | wang_group.add_argument('--y-colors', type=int, default=2, 75 | help='Number of colors to use when tiling up-down. Default = %(default)s') 76 | wang_group.add_argument('--n-choices', type=int, default=2, 77 | help='Number of alternative tile options for each unique pair of tile up-left colors. Default = %(default)s') 78 | wang_group.add_argument('--colorize-edges', action='store_true', 79 | help='Colorize edges in output for easier visual matching. Desaturate to restore original images.') 80 | 81 | args = parser.parse_args() 82 | 83 | if args.seed is None: 84 | args.seed = random.randrange(sys.maxsize) 85 | 86 | if args.frequency is None: 87 | if args.period is None: 88 | parser.error('Either --frequency or --period is required') 89 | else: 90 | args.frequency = 1.0 / args.period 91 | else: 92 | args.period = None # override the default so we can detect which arg was specified 93 | 94 | if args.tile_size < 1: 95 | parser.error('--tile-size must be >= 1') 96 | if not is_power_of_two(args.tile_size): 97 | parser.error('--tile-size must be a power of 2') 98 | if args.period is not None: 99 | if args.period < 1: 100 | parser.error('--period must be >= 1') 101 | if not is_power_of_two(args.period): 102 | parser.error('--period must be a power of 2') 103 | else: 104 | if not 0.0 <= args.frequency <= 1.0: 105 | parser.error('--frequency must be between 0.0 and 1.0') 106 | if not (1.0 / args.frequency).is_integer() or not is_power_of_two(int(1.0 / args.frequency)): 107 | parser.error('--frequency must be the inverse of a power of 2') 108 | if not 0.0 <= args.amplitude <= 1.0: 109 | parser.error('--amplitude must be between 0.0 and 1.0') 110 | if args.octaves < 1: 111 | parser.error('--octaves must be >= 1') 112 | 113 | if args.x_colors < 2: 114 | parser.error('--x-colors must be >= 2') 115 | if args.y_colors < 2: 116 | parser.error('--y-colors must be >= 2') 117 | if args.n_choices < 2: 118 | parser.error('--n-choices must be >= 2') 119 | 120 | return args 121 | 122 | 123 | def main(): 124 | args = parse_args() 125 | logging.basicConfig( 126 | # level=logging.INFO, 127 | level=logging.DEBUG, 128 | format='%(asctime)s | %(filename)s:%(lineno)d | %(levelname)s | %(message)s', 129 | ) 130 | 131 | logger.info(f'Seed: {args.seed}') 132 | random.seed(args.seed) 133 | 134 | perlin_args = PerlinArgs(args.tile_size, args.frequency, args.amplitude, args.octaves) 135 | wang_args = WangArgs(args.x_colors, args.y_colors, args.n_choices, args.colorize_edges) 136 | 137 | logger.info(f'Creating output directory if necessary: {args.output}') 138 | args.output.mkdir(parents=True, exist_ok=True) 139 | 140 | write_tiled_perlin_images(args.output, args.output_name, args.output_format, perlin_args, wang_args) 141 | 142 | 143 | def write_tiled_perlin_images(output_dir: Path, output_name: str, output_format: str, perlin: PerlinArgs, wang: WangArgs) -> Image: 144 | """ Generate all Perlin noise Wang tile images and write them to the specified directory. """ 145 | color_count = wang.x_colors + wang.y_colors 146 | tile_count = wang.x_colors * wang.y_colors * wang.n_choices 147 | max_lattice_size = perlin.lattice_size(perlin.octaves - 1) 148 | 149 | # edge vectors of the same "color" need to stay constant across all tiles 150 | color_to_edge = build_color_to_edge_mapping(color_count, max_lattice_size) 151 | 152 | logger.info(f'Generating {tile_count} images of size {perlin.tile_size}x{perlin.tile_size}') 153 | combined_size = (wang.x_colors * wang.n_choices * perlin.tile_size, wang.y_colors * perlin.tile_size) 154 | combined_img = Image.new(mode='RGBA' if wang.colorize_edges else 'L', size=combined_size) 155 | all_color_combos = list(get_all_wang_colors(wang)) 156 | logger.debug(f'All tile colors: {all_color_combos}') 157 | assert len(all_color_combos) == tile_count 158 | assert sorted(set(all_color_combos)) == sorted(all_color_combos) # check for duplicates 159 | 160 | for idx, color_indices in enumerate(all_color_combos): 161 | perlin_lattice = PerlinLattice(color_to_edge, color_indices) 162 | 163 | output_file = output_dir / f'{output_name}_{"_".join(str(i) for i in color_indices)}.{output_format.lower()}' 164 | logger.info(f'Generating {output_file}') 165 | img = generate_perlin_image(perlin, wang, perlin_lattice) 166 | 167 | if wang.colorize_edges: 168 | img = colorize(img, color_count, color_indices) 169 | 170 | logger.info(f'Saving {output_file}') 171 | img.save(output_file) 172 | 173 | x_idx = idx % (wang.x_colors * wang.n_choices) 174 | y_idx = idx // (wang.x_colors * wang.n_choices) 175 | combined_img.paste(img, box=(x_idx * perlin.tile_size, y_idx * perlin.tile_size)) 176 | 177 | combined_output_file = output_dir / f'{output_name}_combined.{output_format.lower()}' 178 | logger.info(f'Saving {combined_output_file}') 179 | combined_img.save(combined_output_file) 180 | 181 | logger.info('Done. Color indexes in filenames are in NESW (top-right-bottom-left) order.') 182 | 183 | 184 | def build_color_to_edge_mapping(n_colors: int, max_lattice_size: int) -> ColorToEdgeDict: 185 | """ Build a mapping of color index to edge permutations. """ 186 | 187 | def make_color_edge() -> ColorEdge: 188 | """ Build a single list of edge permutations. """ 189 | assert max_lattice_size <= PerlinLattice.GRADIENT_COUNT, max_lattice_size 190 | return random.sample(range(0, PerlinLattice.GRADIENT_COUNT), max_lattice_size) 191 | 192 | return {color_idx: make_color_edge() for color_idx in range(n_colors)} 193 | 194 | 195 | def get_all_wang_colors(wang: WangArgs) -> Iterable[EdgeColors]: 196 | """ Generate a (non-minimal) list of color combinations to create a non-periodic Wang tiling. """ 197 | # No idea if this is a good algorithm, I just made something up 198 | tile_count = wang.x_colors * wang.y_colors * wang.n_choices 199 | color_pairs = list(itertools.product(range(wang.x_colors), range(wang.y_colors))) 200 | nw_colors = color_pairs * wang.n_choices 201 | rotation_amount = len(nw_colors) // 2 + 1 202 | se_colors = rotated(list(itertools.chain.from_iterable(itertools.repeat(colors, wang.n_choices) for colors in color_pairs)), 203 | rotation_amount) 204 | 205 | # shift the up-down tile ids to be unique 206 | # mostly for clarity, but also allows us to use a single dict for colors rather than separate ones for NS and EW 207 | # technically it is just as correct to re-use colors between axes since tile rotation is disallowed when tiling 208 | nw_colors = [(left_color, up_color + wang.x_colors) for left_color, up_color in nw_colors] 209 | se_colors = [(right_color, down_color + wang.x_colors) for right_color, down_color in se_colors] 210 | logger.debug(f'North-west color list: {nw_colors}') 211 | logger.debug(f'South-east color list: {se_colors}') 212 | 213 | assert len(nw_colors) == len(se_colors) 214 | for (left_color, up_color), (right_color, down_color) in zip(nw_colors, se_colors): 215 | yield EdgeColors(top=up_color, right=right_color, bottom=down_color, left=left_color) 216 | 217 | 218 | def rotated(l: list, n: int) -> list: 219 | """ Rotate the specified list `l` forward by `n` places. """ 220 | return l[n:] + l[:n] 221 | 222 | 223 | def generate_perlin_image(perlin: PerlinArgs, wang: WangArgs, lattice: PerlinLattice) -> Image: 224 | """ Build a single noise tile image. """ 225 | img = Image.new(mode='RGBA' if wang.colorize_edges else 'L', size=(perlin.tile_size, perlin.tile_size)) 226 | buf = list(img.getdata()) 227 | for idx, px in enumerate(buf): 228 | point = Vector2(idx % perlin.tile_size, idx // perlin.tile_size) 229 | value = ((noise(point, perlin, wang, lattice) / 2.0) + 0.5) * 255 230 | buf[idx] = (int(value), int(value), int(value)) if wang.colorize_edges else value 231 | img.putdata(buf) 232 | return img 233 | 234 | 235 | def noise(point: Vector2, perlin: PerlinArgs, wang: WangArgs, lattice: PerlinLattice) -> float: # [-1.0, 1.0] 236 | """ Calculate Perlin noise at point with multiple octaves and overridden edge gradients. """ 237 | # weighted sum of noise with increasing frequencies 238 | result = 0.0 239 | for octave in range(perlin.octaves): 240 | lattice_size = perlin.lattice_size(octave) 241 | 242 | # sanity check that all corners share the same gradient 243 | assert more_itertools.all_equal(lattice.get_gradient_vector(corner, lattice_size) 244 | for corner in lattice.get_all_corners(lattice_size)) 245 | 246 | weight = perlin.amplitude / 2**octave 247 | result += weight * _noise( 248 | point=(point * perlin.frequency * 2**octave), 249 | lattice=lattice, 250 | lattice_size=lattice_size, 251 | ) 252 | assert -1.0 <= result <= 1.0, result 253 | return result 254 | 255 | 256 | def _noise(point: Vector2, lattice: PerlinLattice, lattice_size: int) -> float: # [-1.0, 1.0] 257 | """ Calculate one octave of Perlin noise at point with overridden edge gradients. """ 258 | top_left_corner = Vector2(np.floor(point)) 259 | corners = [ 260 | top_left_corner, 261 | top_left_corner + Vector2(1, 0), 262 | top_left_corner + Vector2(0, 1), 263 | top_left_corner + Vector2(1, 1), 264 | ] 265 | result = sum(lattice.gradient(point, corner, lattice_size) for corner in corners) 266 | 267 | assert -1.0 <= result <= 1.0, result 268 | return result 269 | 270 | 271 | def colorize(img: Image, color_count: int, color_indices: EdgeColors) -> Image: 272 | """ Colorize the edges of the specified tile image for easier visual matching. """ 273 | THICKNESS = 0.5 # full triangular "quadrant" 274 | TRAPEZOIDS = [ 275 | [(0, 0), (1, 0), (1 - THICKNESS, THICKNESS), (THICKNESS, THICKNESS)], # top 276 | [(1, 0), (1, 1), (1 - THICKNESS, 1 - THICKNESS), (1 - THICKNESS, THICKNESS)], # right 277 | [(1, 1), (0, 1), (THICKNESS, 1 - THICKNESS), (1 - THICKNESS, 1 - THICKNESS)], # bottom 278 | [(0, 1), (0, 0), (THICKNESS, THICKNESS), (THICKNESS, 1 - THICKNESS)], # left 279 | ] 280 | 281 | color_map = make_color_map(color_count, saturation=0.5) 282 | 283 | color_img = Image.new('RGBA', size=img.size) 284 | draw = ImageDraw.Draw(color_img) 285 | for side_idx, trapezoid in enumerate(TRAPEZOIDS): 286 | color = color_map[color_indices[side_idx]] 287 | scaled_trapezoid = [tuple(Vector2(vertex) * Vector2(img.size)) for vertex in trapezoid] 288 | draw.polygon(scaled_trapezoid, fill=tuple(int(c * 255) for c in color)) 289 | 290 | return blend_color(img, color_img) 291 | 292 | 293 | def make_color_map(color_count: int, saturation: float) -> dict[int, tuple[int, int, int]]: 294 | """ Build a mapping of color index to a unique RGB triple. Distribute hues as evenly as possible to (ideally) avoid confusion. """ 295 | hues = np.linspace(0.0, 1.0, num=color_count, endpoint=False) 296 | assert len(hues) == color_count 297 | return {color_idx: (*colorsys.hsv_to_rgb(hues[color_idx], saturation, 1.0), 1.0) for color_idx in range(color_count)} 298 | 299 | 300 | def blend_color(background: Image, foreground: Image) -> Image: 301 | """ Blend the colors from `foreground` into the `background` image. """ 302 | rgb_to_hsv = np.vectorize(colorsys.rgb_to_hsv) 303 | hsv_to_rgb = np.vectorize(colorsys.hsv_to_rgb) 304 | 305 | bg_array = np.asarray(background).astype(float) # dimensions: x, y, rgba 306 | fg_array = np.asarray(foreground).astype(float) 307 | 308 | bg_array_transposed = np.moveaxis(bg_array, source=2, destination=0) # dimensions: rgba, x, y 309 | fg_array_transposed = np.moveaxis(fg_array, source=2, destination=0) 310 | 311 | bg_r, bg_g, bg_b, bg_a = bg_array_transposed 312 | fg_r, fg_g, fg_b, fg_a = fg_array_transposed 313 | 314 | bg_h, bg_s, bg_v = rgb_to_hsv(bg_r, bg_g, bg_b) 315 | fg_h, fg_s, fg_v = rgb_to_hsv(fg_r, fg_g, fg_b) 316 | 317 | # take hue/saturation from foreground, and take value from background 318 | out_r, out_g, out_b = hsv_to_rgb(fg_h, fg_s, bg_v) 319 | out_arr = np.dstack((out_r, out_g, out_b, bg_a)) 320 | 321 | return Image.fromarray(out_arr.astype('uint8'), 'RGBA') 322 | 323 | 324 | if __name__ == '__main__': 325 | main() 326 | --------------------------------------------------------------------------------