Source code for xrspatial.perlin

from __future__ import annotations

# std lib
import math
from functools import partial

# 3rd-party
import numpy as np
import xarray as xr

try:
    import cupy
except ImportError:
    class cupy(object):
        ndarray = False

try:
    import dask
    import dask.array as da
except ImportError:
    dask = None
    da = None

import numba as nb
from numba import cuda, jit

# local modules
from xrspatial.utils import (ArrayTypeFunctionMapping, _validate_raster, cuda_args,
                             not_implemented_func)


def _make_perm_table(seed):
    """Build a 512-element permutation table (256 unique values, doubled for wrap-around).

    Uses RandomState to avoid mutating the global numpy RNG.
    """
    rs = np.random.RandomState(seed)
    p = rs.permutation(256).astype(np.int32)
    return np.append(p, p)


@jit(nopython=True, nogil=True)
def _lerp(a, b, x):
    return a + x * (b - a)


@jit(nopython=True, nogil=True)
def _fade(t):
    return 6 * t ** 5 - 15 * t ** 4 + 10 * t ** 3


@jit(nopython=True, nogil=True)
def _gradient(h, x, y):
    out = np.zeros(h.shape, dtype=np.float32)
    for j in nb.prange(h.shape[1]):
        for i in nb.prange(h.shape[0]):
            hv = h[i, j] & 3
            sel = (hv >> 1) & 1  # 0 -> y axis, 1 -> x axis
            u = x[i, j] * sel + y[i, j] * (1 - sel)
            out[i, j] = u * (1 - 2 * (hv & 1))
    return out


def _perlin(p, x, y):
    # coordinates of the top-left (floor, not truncate, so negatives work)
    x_floor = np.floor(x)
    y_floor = np.floor(y)
    xi = x_floor.astype(np.int32)
    yi = y_floor.astype(np.int32)

    # mask to 0-255 range for 512-element table
    xi = xi & 255
    yi = yi & 255

    # internal coordinates
    xf = x - x_floor
    yf = y - y_floor

    # fade factors
    u = _fade(xf)
    v = _fade(yf)

    # noise components
    n00 = _gradient(p[p[xi] + yi], xf, yf)
    n01 = _gradient(p[p[xi] + yi + 1], xf, yf - 1)
    n11 = _gradient(p[p[xi + 1] + yi + 1], xf - 1, yf - 1)
    n10 = _gradient(p[p[xi + 1] + yi], xf - 1, yf)

    # combine noises
    x1 = _lerp(n00, n10, u)
    x2 = _lerp(n01, n11, u)
    a = _lerp(x1, x2, v)
    return a


def _perlin_numpy(data: np.ndarray,
                  freq: tuple,
                  seed: int) -> np.ndarray:
    p = _make_perm_table(seed)

    height, width = data.shape
    linx = np.linspace(0, freq[0], width, endpoint=False, dtype=np.float32)
    liny = np.linspace(0, freq[1], height, endpoint=False, dtype=np.float32)
    x, y = np.meshgrid(linx, liny)

    data[:] = _perlin(p, x, y)
    data[:] = (data - np.min(data)) / np.ptp(data)
    return data


def _perlin_dask_numpy(data: da.Array,
                       freq: tuple,
                       seed: int) -> da.Array:
    p = _make_perm_table(seed)

    height, width = data.shape
    linx = da.linspace(0, freq[0], width, endpoint=False, dtype=np.float32,
                       chunks=data.chunks[1][0])
    liny = da.linspace(0, freq[1], height, endpoint=False, dtype=np.float32,
                       chunks=data.chunks[0][0])
    x, y = da.meshgrid(linx, liny)

    _func = partial(_perlin, p)
    data = da.map_blocks(_func, x, y, meta=np.array((), dtype=np.float32))

    # persist so min/ptp don't recompute the noise from scratch
    (data,) = dask.persist(data)
    min_val, ptp_val = dask.compute(da.min(data), da.ptp(data))
    data = (data - min_val) / ptp_val
    return data


@cuda.jit(device=True)
def _lerp_gpu(a, b, x):
    return a + x * (b - a)


@cuda.jit(device=True)
def _fade_gpu(t):
    return 6 * t ** 5 - 15 * t ** 4 + 10 * t ** 3


@cuda.jit(device=True)
def _gradient_gpu(h, x, y):
    hv = h & 3
    sel = (hv >> 1) & 1
    u = x * sel + y * (1 - sel)
    return u * (1 - 2 * (hv & 1))


@cuda.jit(fastmath=True, opt=True)
def _perlin_gpu(p, x0, x1, y0, y1, m, out):
    # cooperative load of permutation table into shared memory
    sp = cuda.shared.array(512, nb.int32)
    tx = cuda.threadIdx.x
    ty = cuda.threadIdx.y
    bw = cuda.blockDim.x
    bh = cuda.blockDim.y
    tid = ty * bw + tx
    block_size = bw * bh
    for k in range(tid, 512, block_size):
        sp[k] = p[k]
    cuda.syncthreads()

    i, j = nb.cuda.grid(2)
    if i < out.shape[0] and j < out.shape[1]:
        # coordinates of the top-left
        y = y0 + i * (y1 - y0) / out.shape[0]
        x = x0 + j * (x1 - x0) / out.shape[1]

        # integer coordinates masked to table range
        # floor, not truncate, so negative coords work correctly
        x_int = int(math.floor(x)) & 255
        y_int = int(math.floor(y)) & 255

        # internal coordinates
        xf = x - math.floor(x)
        yf = y - math.floor(y)

        # fade factors
        u = _fade_gpu(xf)
        v = _fade_gpu(yf)

        # noise components (all reads from shared memory)
        n00 = _gradient_gpu(sp[sp[x_int] + y_int], xf, yf)
        n01 = _gradient_gpu(sp[sp[x_int] + y_int + 1], xf, yf - 1)
        n11 = _gradient_gpu(sp[sp[x_int + 1] + y_int + 1], xf - 1, yf - 1)
        n10 = _gradient_gpu(sp[sp[x_int + 1] + y_int], xf - 1, yf)

        # combine noises
        x1 = _lerp_gpu(n00, n10, u)
        x2 = _lerp_gpu(n01, n11, u)
        out[i, j] = m * _lerp_gpu(x1, x2, v)


@cuda.jit(fastmath=True, opt=True)
def _perlin_gpu_xy(p, x_arr, y_arr, m, out):
    """Like _perlin_gpu but takes 2D coordinate arrays instead of linear ranges.

    Needed for domain warping where per-pixel coordinates are non-linear.
    """
    sp = cuda.shared.array(512, nb.int32)
    tx = cuda.threadIdx.x
    ty = cuda.threadIdx.y
    bw = cuda.blockDim.x
    bh = cuda.blockDim.y
    tid = ty * bw + tx
    block_size = bw * bh
    for k in range(tid, 512, block_size):
        sp[k] = p[k]
    cuda.syncthreads()

    i, j = nb.cuda.grid(2)
    if i < out.shape[0] and j < out.shape[1]:
        x = x_arr[i, j]
        y = y_arr[i, j]

        x_int = int(math.floor(x)) & 255
        y_int = int(math.floor(y)) & 255

        xf = x - math.floor(x)
        yf = y - math.floor(y)

        u = _fade_gpu(xf)
        v = _fade_gpu(yf)

        n00 = _gradient_gpu(sp[sp[x_int] + y_int], xf, yf)
        n01 = _gradient_gpu(sp[sp[x_int] + y_int + 1], xf, yf - 1)
        n11 = _gradient_gpu(sp[sp[x_int + 1] + y_int + 1], xf - 1, yf - 1)
        n10 = _gradient_gpu(sp[sp[x_int + 1] + y_int], xf - 1, yf)

        x1 = _lerp_gpu(n00, n10, u)
        x2 = _lerp_gpu(n01, n11, u)
        out[i, j] = m * _lerp_gpu(x1, x2, v)


def _perlin_cupy(data: cupy.ndarray,
                 freq: tuple,
                 seed: int) -> cupy.ndarray:

    p = cupy.asarray(_make_perm_table(seed))

    griddim, blockdim = cuda_args(data.shape)
    _perlin_gpu[griddim, blockdim](p, 0, freq[0], 0, freq[1], 1, data)

    minimum = cupy.amin(data)
    maximum = cupy.amax(data)
    data[:] = (data - minimum) / (maximum - minimum)
    return data


def _perlin_dask_cupy(data: da.Array,
                      freq: tuple,
                      seed: int) -> da.Array:
    p = cupy.asarray(_make_perm_table(seed))

    height, width = data.shape

    def _chunk_perlin(block, block_info=None):
        info = block_info[0]
        y_start, y_end = info['array-location'][0]
        x_start, x_end = info['array-location'][1]
        x0 = freq[0] * x_start / width
        x1 = freq[0] * x_end / width
        y0 = freq[1] * y_start / height
        y1 = freq[1] * y_end / height

        out = cupy.empty(block.shape, dtype=cupy.float32)
        griddim, blockdim = cuda_args(block.shape)
        _perlin_gpu[griddim, blockdim](p, x0, x1, y0, y1, 1.0, out)
        return out

    data = da.map_blocks(_chunk_perlin, data, dtype=cupy.float32,
                         meta=cupy.array((), dtype=cupy.float32))

    # persist so min/max don't recompute the noise from scratch
    (data,) = dask.persist(data)
    min_val, max_val = dask.compute(da.min(data), da.max(data))
    data = (data - min_val) / (max_val - min_val)
    return data


[docs] def perlin(agg: xr.DataArray, freq: tuple = (1, 1), seed: int = 5, name: str = 'perlin') -> xr.DataArray: """ Generate perlin noise aggregate. Parameters ---------- agg : xr.DataArray 2D array of size width x height, will be used to determine height/ width and which platform to use for calculation. freq : tuple, default=(1,1) (x, y) frequency multipliers. seed : int, default=5 Seed for random number generator. Returns ------- perlin_agg : xarray.DataArray 2D array of perlin noise values. References ---------- - Paul Panzer: https://stackoverflow.com/questions/42147776/producing-2d-perlin-noise-with-numpy # noqa - ICA: http://www.mountaincartography.org/mt_hood/pdfs/nighbert_bump1.pdf # noqa Examples -------- .. sourcecode:: python >>> import numpy as np >>> import xarray as xr >>> from xrspatial import perlin >>> W = 4 >>> H = 3 >>> data = np.zeros((H, W), dtype=np.float32) >>> raster = xr.DataArray(data, dims=['y', 'x']) >>> perlin_noise = perlin(raster) >>> print(perlin_noise) <xarray.DataArray 'perlin' (y: 3, x: 4)> array([[0. , 0.58311844, 0.96620274, 0.58311844], [0. , 0.5796199 , 1. , 0.7346118 ], [0. , 0.5172636 , 0.83896613, 0.697184 ]], dtype=float32) Dimensions without coordinates: y, x """ _validate_raster(agg, func_name='perlin', name='agg') # perlin writes float noise into the raster in place, then normalizes # by ptp. With an integer buffer the float values cast to 0, ptp is 0, # and the normalization divides by zero, corrupting every pixel to # INT_MIN. Reject non-float dtypes up front with a clear error. if not np.issubdtype(agg.dtype, np.floating): raise ValueError( f"perlin(): `agg` must have a floating-point dtype " f"(float32 or float64), got {agg.dtype}" ) mapper = ArrayTypeFunctionMapping( numpy_func=_perlin_numpy, cupy_func=_perlin_cupy, dask_func=_perlin_dask_numpy, dask_cupy_func=_perlin_dask_cupy ) out = mapper(agg)(agg.data, freq, seed) result = xr.DataArray(out, dims=agg.dims, attrs=agg.attrs, name=name) return result