import math
import random
import copy
Introduction
This post goes through the code for a Python Poisson Disk Sampling package. The code comes from Robert Bridson’s algorithm. The posts from Sighack and Jason Davies are also helpful resources to have.
Basically, we’ll start with one point then add another point within certain bounds from that one. We’ll continue adding new points off of these points and so on as long as the new points aren’t too close to any other point. When no new points can be added, the algorithm is done.
The points in this code will form a repeating pattern if allowed.
Code
We’ll start by importing the libraries.
Then we’ll create a Point
class. It’s basically just a holder for the location of a single point.
class Point:
"""
Class for a singluar point
Attributes
----------
order: int
The order the point was inserted into the class list
x: float
x-location of the point
y: float
y-location of the point
"""
def __init__(self,
int,
order: float,
x: float) -> None:
y: """
Constructs a point
Parameters
----------
order: int
The order the point was inserted into the class list
x: float
x-location of the point
y: float
y-location of the point
"""
self.order = order
self.x = x
self.y = y
def __repr__(self) -> str:
"""repr for Point class"""
return f'Point(order={self.order}, x={self.x}, y={self.y})'
def __str__(self) -> str:
"""str for Point class"""
return f'{self.order}: {self.x}, {self.y}'
def point_information(self) -> list:
"""Returns the Point information as a list"""
return [self.order, self.x, self.y]
Now, we’ll start to build up the main code section. First is the PoissonDiskSampling
class. This will hold the main code for the algorithm. We’ll start out with the __init__
method to set up the basic attributes.
We’ll need to keep track of:
radius
to know how far to search around a point. This determines how close points are together.grid_height
andgrid_width
to know how big of an area to fill.sample_limit
to know when to stop trying for new points. This can affect how filled in the area gets.seed
to make it reproducible.initial_point
for an optional starting point.
These attributes will be used to build the backgroud grid, grid
. This is used to speed up comparing a potential point to the current list of accepted points, points
. Finally, there will be a very similar list of points that are kept to anchor potential points, active_list
.
class PoissonDiskSampling:
"""
Class for poisson disk sampling points
Attributes
----------
points: list
The main list of points
"""
def __init__(self,
float,
radius: float,
grid_width: float,
grid_height: int = 30,
sample_limit: int = None,
seed: = None) -> None:
inital_point: Point """
Constructor for the poisson disk sample
Parameters
----------
radius: float
The minimum distance between points
grid_width: float
The width of the grid
grid_height: float
The height of the grid
sample_limit: int
The number of times to try to find a new sample point for
each attempt with an active point. Decreasing will speed up
the process, but increasing will fill in more points.
seed: int
Seed for the random number generator, if needed.
inital_point: Point
Inital point to use. If one is not provided, then one is
choosen at random. This is mostly used for testing.
"""
self.radius = radius
self.grid_width = grid_width
self.grid_height = grid_height
self.sample_limit = sample_limit
# These are called often in the code. So they are pre-calculated
self.cell_size: float = self.radius / math.sqrt(2)
self.bounds: int = math.ceil(self.radius/self.cell_size) # 2
self.wrap_cutoff = self.bounds*2
self.grid_cell_width: int = math.ceil(
self.grid_width / self.cell_size)
self.grid_cell_height: int = math.ceil(
self.grid_height / self.cell_size)
self.radius_2: float = (self.radius)**2
# These hold the points, one as a list and one as a 2d array.
# The grid has room to hold duplicated points on the edges to
# allow checking for point distance when the pattern repeats.
self.points: list[list] = []
self.grid: list[list] = [
None for i in range(self.grid_cell_height + self.bounds*2)]
[for j in range(self.grid_cell_width + self.bounds*2)]
# This is list of point to sample from until they are checked
# for the self.sample_limit attribute
self.active_list: list = []
We can finish up the rest of the __init__
method that will sample all the points. It starts by determining the first point. Then uses a while loop to sample new points until all of the accepted points have been checked.
# Set up the start
if seed is not None:
random.seed(seed)
if inital_point is not None:
self.insert_point(inital_point)
else:
self.insert_point(
0,
Point(* self.grid_width,
random.random() * self.grid_height))
random.random()
# Continue until all points are checked
# Randomly pull a point that hasn't been removed each time
while self.active_list:
self.sample(random.randrange(len(self.active_list)))
In the previous code block, the sample
method is called in a while loop. We’ll now build up two other methods, insert_point
and check_neighbors
, to then get to sample
.
The insert_point
method takes a new point and inserts it into the grid
and points
list. The main difficulty is in checking the edges to have points repeat if needed.
def insert_point(self,
-> None:
p: Point) """
Inserts a new point into the self.points list and the self.grid
array. If the point is near the edge, extra points will be added
to the grid so the pattern can repeat itself without having
points too close to each other.
"""
int = math.floor(p.x/self.cell_size) + self.bounds
cell_x: int = math.floor(p.y/self.cell_size) + self.bounds
cell_y:
self.grid[cell_x][cell_y] = p
self.points.append(p.point_information())
# If on the edges, loop around
# Check along sides
bool = False
flip_x: int
cell_x_wrap: float
x_wrap: if cell_x < self.wrap_cutoff:
= True
flip_x = self.grid_cell_width + cell_x
cell_x_wrap = p.x + self.grid_width
x_wrap elif cell_x >= self.grid_cell_width:
= True
flip_x = cell_x - self.grid_cell_width
cell_x_wrap = p.x - self.grid_width
x_wrap
if flip_x:
self.grid[cell_x_wrap][cell_y] = Point(p.order, x_wrap, p.y)
# Check along top and bottom
bool = False
flip_y: int
cell_y_wrap: float
y_wrap: if cell_y < self.wrap_cutoff:
= True
flip_y = self.grid_cell_height + cell_y
cell_y_wrap = p.y + self.grid_height
y_wrap elif cell_y >= self.grid_cell_height:
= True
flip_y = cell_y - self.grid_cell_height
cell_y_wrap = p.y - self.grid_height
y_wrap
if flip_y:
self.grid[cell_x][cell_y_wrap] = Point(p.order, p.x, y_wrap)
# Check corners
if flip_x and flip_y:
self.grid[cell_x_wrap][cell_y_wrap] = Point(
p.order, x_wrap, y_wrap)
# Insert into active list
self.active_list.append(p)
The check_neighbors
method uses the grid
to see if the new points is too close to any other points.
def check_neighbors(self,
float,
x: float) -> bool:
y: """Checks if there are any points too close a location."""
int = math.floor(x/self.cell_size) + self.bounds
cell_x: int = math.floor(y/self.cell_size) + self.bounds
cell_y:
int
column: int
row: for column in range(cell_x - 2, cell_x + 3):
for row in range(cell_y - 2, cell_y + 3):
| None = self.grid[column][row]
neighbor: Point if neighbor:
float = neighbor.x - x
dx: float = neighbor.y - y
dy: if (dx**2 + dy**2) < self.radius_2:
return False
return True
The sample
method takes in an index for a point from the active_list
, pulls a new point around that point, and then checks to see if it’s good with check_neighbors
. If it is good, then it calls insert_point
. If enough attempts are made without adding a new point, the first point is removed from active_list
.
Finally, the sample_points
method pulls everything together and repeated the points pattern if requested.
def sample_points(radius: float,
float,
grid_width: float,
grid_height: int = 30,
sample_limit: float = .25,
repeat_percentage: int = None,
seed: = None) -> None:
inital_point: Point """
Creates a list of points from poisson disk sampling with optional
repeats along the edges.
Parameters
----------
radius: float
The minimum distance between points
grid_width: float
The width of the grid
grid_height: float
The height of the grid
sample_limit: int
The number of times to try to find a new sample point for
each attempt with an active point. Decreasing will speed up
the process, but increasing will fill in more points.
repeat_percentage: float
How much to repeat points around the borders. 0 for no repeats
1 for full repeats.
seed: int
Seed for the random number generator, if needed.
inital_point: Point
Inital point to use. If one is not provided, then one is
choosen at random. This is mostly used for testing.
"""
= PoissonDiskSampling(radius=radius,
samp: PoissonDiskSampling =grid_width,
grid_width=grid_height,
grid_height=sample_limit,
sample_limit=seed,
seed=inital_point)
inital_pointif repeat_percentage == 0:
return samp.points
# Find boundaries from repeat_percentage
float = 0 - (grid_width * repeat_percentage)
width_min: float = grid_width + (grid_width * repeat_percentage)
width_max: float = 0 - (grid_height * repeat_percentage)
height_min: float = grid_height + (grid_height * repeat_percentage)
height_max:
# Fill list with base points and copies for repetition
list = copy.deepcopy(samp.points)
points:
int
column: int
row: list
p: for column in range(-1, 2):
for row in range(-1, 2):
if column != 0 or row != 0:
list = copy.deepcopy(samp.points)
rep_points: for p in rep_points:
1] = p[1] + column * grid_width
p[2] = p[2] + row * grid_height
p[# Check if the repeated points are in bounds
= [p for p in rep_points
rep_points if p[1] >= width_min
and p[1] <= width_max
and p[2] >= height_min
and p[2] <= height_max]
points.extend(rep_points)
return points
A package for the code can be found here.
# install the package
+https://github.com/WilliamTylerBradley/poisson_disk_sampling pip install git
Here’s some example code. The repetition of points is visible here.
import matplotlib.pyplot as plt
import numpy as np
import PoissonDiskSampling as pds
= 20
height = 20
width = 2
radius
= pds.sample_points(radius, width, height, seed=0)
samp = np.array(samp)
points
= plt.subplots()
fig, ax 1], points[:, 2])
ax.scatter(points[:, 'equal')
ax.set_aspect( plt.show()