# Power Spectra for "Blue" and Uniform Noise

The previous post provided some code for generating Poisson disc noise (in which no point the sample is closer than some fixed distance from any other). Here is a short program to calculate the power spectrum of this noise and compare it with the spectrum for the same number of points drawn from a uniform distribution.

Top: Poisson disc noise (left) and uniform noise (right) and their power spectra (bottom). As expected, the spectrum of the uniform noise is featureless, whereas the spectrum for Poisson disc noise has concentric circles around a blank disc.

The code below uses a version of the Poisson Disc algorithm that I have cast into object-oriented form (also given below).

This code is also available on my github page.

import numpy as np
import matplotlib.pyplot as plt
from poisson import PoissonDisc

class UniformNoise():
"""A class for generating uniformly distributed, 2D noise."""

def __init__(self, width=50, height=50, n=None):
"""Initialise the size of the domain and number of points to sample."""
self.width, self.height = width, height
if n is None:
n = int(width * height)
self.n = n

def reset(self):
pass

def sample(self):
return np.array([np.random.uniform(0, width, size=self.n),
np.random.uniform(0, height, size=self.n)]).T

# domain size, minimum distance between samples for Poisson disc method...
width = height = 100
r = 2
poisson_disc = PoissonDisc(width, height, r)
# Expected number of samples from Poisson disc method...
n = int(width * height / np.pi / poisson_disc.a**2)
# ... use the same for uniform noise.
uniform_noise = UniformNoise(width, height, n)

# Number of sampling runs to do (to remove noise from the noise in the power
# spectrum).
N = 100
# Sampling parameter, when putting the sample points onto the domain
M = 5

fig, ax = plt.subplots(nrows=2, ncols=2)

for j, noise in enumerate((poisson_disc, uniform_noise)):
print(noise.__class__.__name__)
spec = np.zeros((height * M, width * M))
for i in range(N):
print('{}/{}'.format(i+1, N))
noise.reset()
samples = np.array(noise.sample())
domain = np.zeros((height * M, width * M))
for pt in samples:
coords = int(pt[1] * M), int(pt[0] * M)
domain[coords] = 1

# Do the Fourier Trasform, shift the frequencies and add to the
# running total.
f = np.fft.fft2(domain)
fshift = np.fft.fftshift(f)
spec += np.log(np.abs(fshift))

# Plot the a set of random points and the power spectrum.
ax[0][j].imshow(domain, cmap=plt.cm.Greys)
ax[1][j].imshow(spec, cmap=plt.cm.Greys_r)
# Remove axis ticks and annotations
for k in (0,1):
ax[k][j].tick_params(which='both', bottom='off', left='off',
top='off', right='off', labelbottom='off', labelleft='off')

plt.savefig('periodograms.png')


The class PoissonDisc is defined in the file poisson.py given here:

import numpy as np
import matplotlib.pyplot as plt

class PoissonDisc():
def __init__(self, width=50, height=50, r=1, k=30):
self.width, self.height = width, height
self.r = r
self.k = k

# Cell side length
self.a = r/np.sqrt(2)
# Number of cells in the x- and y-directions of the grid
self.nx, self.ny = int(width / self.a) + 1, int(height / self.a) + 1

self.reset()

def reset(self):
"""Reset the cells dictionary."""

# A list of coordinates in the grid of cells
coords_list = [(ix, iy) for ix in range(self.nx)
for iy in range(self.ny)]
# Initilalize the dictionary of cells: each key is a cell's coordinates
# the corresponding value is the index of that cell's point's
# coordinates in the samples list (or None if the cell is empty).
self.cells = {coords: None for coords in coords_list}

def get_cell_coords(self, pt):
"""Get the coordinates of the cell that pt = (x,y) falls in."""

return int(pt[0] // self.a), int(pt[1] // self.a)

def get_neighbours(self, coords):
"""Return the indexes of points in cells neighbouring cell at coords.
For the cell at coords = (x,y), return the indexes of points in the
cells with neighbouring coordinates illustrated below: ie those cells
that could contain points closer than r.

ooo
ooooo
ooXoo
ooooo
ooo

"""

dxdy = [(-1,-2),(0,-2),(1,-2),(-2,-1),(-1,-1),(0,-1),(1,-1),(2,-1),
(-2,0),(-1,0),(1,0),(2,0),(-2,1),(-1,1),(0,1),(1,1),(2,1),
(-1,2),(0,2),(1,2),(0,0)]
neighbours = []
for dx, dy in dxdy:
neighbour_coords = coords[0] + dx, coords[1] + dy
if not (0 <= neighbour_coords[0] < self.nx and
0 <= neighbour_coords[1] < self.ny):
# We're off the grid: no neighbours here.
continue
neighbour_cell = self.cells[neighbour_coords]
if neighbour_cell is not None:
# This cell is occupied: store the index of the contained point
neighbours.append(neighbour_cell)
return neighbours

def point_valid(self, pt):
"""Is pt a valid point to emit as a sample?

It must be no closer than r from any other point: check the cells in
its immediate neighbourhood.

"""

cell_coords = self.get_cell_coords(pt)
for idx in self.get_neighbours(cell_coords):
nearby_pt = self.samples[idx]
# Squared distance between candidate point, pt, and this nearby_pt.
distance2 = (nearby_pt[0]-pt[0])**2 + (nearby_pt[1]-pt[1])**2
if distance2 < self.r**2:
# The points are too close, so pt is not a candidate.
return False
# All points tested: if we're here, pt is valid
return True

def get_point(self, refpt):
"""Try to find a candidate point near refpt to emit in the sample.

We draw up to k points from the annulus of inner radius r, outer radius
2r around the reference point, refpt. If none of them are suitable
(because they're too close to existing points in the sample), return
False. Otherwise, return the pt.

"""

i = 0
while i < self.k:
rho, theta = (np.random.uniform(self.r, 2*self.r),
np.random.uniform(0, 2*np.pi))
pt = refpt[0] + rho*np.cos(theta), refpt[1] + rho*np.sin(theta)
if not (0 <= pt[0] < self.width and 0 <= pt[1] < self.height):
# This point falls outside the domain, so try again.
continue
if self.point_valid(pt):
return pt
i += 1
# We failed to find a suitable point in the vicinity of refpt.
return False

def sample(self):
"""Poisson disc random sampling in 2D.

Draw random samples on the domain width x height such that no two
samples are closer than r apart. The parameter k determines the
maximum number of candidate points to be chosen around each reference
point before removing it from the "active" list.

"""

pt = (np.random.uniform(0, self.width),
np.random.uniform(0, self.height))
self.samples = [pt]
# Our first sample is indexed at 0 in the samples list...
self.cells[self.get_cell_coords(pt)] = 0
# and it is active, in the sense that we're going to look for more
# points in its neighbourhood.
active = [0]

# As long as there are points in the active list, keep looking for
# samples.
while active:
# choose a random "reference" point from the active list.
idx = np.random.choice(active)
refpt = self.samples[idx]
# Try to pick a new point relative to the reference point.
pt = self.get_point(refpt)
if pt:
# Point pt is valid: add it to samples list and mark as active
self.samples.append(pt)
nsamples = len(self.samples) - 1
active.append(nsamples)
self.cells[self.get_cell_coords(pt)] = nsamples
else:
# We had to give up looking for valid points near refpt, so
# remove it from the list of "active" points.
active.remove(idx)

return self.samples

Current rating: 3.5