| 3 min read

Building Anti-Clustering Heuristics for Generative Art

generative art algorithms spatial logic Python creative coding heuristics

The Clustering Problem in Generative Art

When you randomly place elements on a canvas, they inevitably cluster. Random does not mean evenly distributed. Some areas end up dense while others remain empty. For generative art, this creates compositions that look unbalanced and amateur. The human eye expects a degree of spatial harmony that pure randomness cannot provide.

I built anti-clustering heuristics for an autonomous art generation system, and the visual quality difference was dramatic. Here is how the algorithms work.

Minimum Distance Enforcement

The simplest anti-clustering technique: reject any placement that is too close to an existing element.

import numpy as np
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float
    radius: float = 1.0

class MinDistancePlacer:
    def __init__(self, min_distance: float, canvas_size: tuple[float, float]):
        self.min_distance = min_distance
        self.canvas_w, self.canvas_h = canvas_size
        self.placed: list[Point] = []
    
    def try_place(self, max_attempts: int = 100) -> Point | None:
        for _ in range(max_attempts):
            candidate = Point(
                x=np.random.uniform(0, self.canvas_w),
                y=np.random.uniform(0, self.canvas_h)
            )
            
            if self._is_valid(candidate):
                self.placed.append(candidate)
                return candidate
        
        return None  # Could not find valid placement
    
    def _is_valid(self, candidate: Point) -> bool:
        for existing in self.placed:
            dist = np.sqrt(
                (candidate.x - existing.x) ** 2 + 
                (candidate.y - existing.y) ** 2
            )
            if dist < self.min_distance:
                return False
        return True

The Problem with Simple Rejection

Simple rejection sampling works but has two issues: it gets exponentially slower as the canvas fills up, and it does not guarantee good distribution. We can do better.

Poisson Disk Sampling

Poisson disk sampling produces a distribution where no two points are closer than a minimum distance, while still appearing natural and random. It is the gold standard for anti-clustering in generative art.

class PoissonDiskSampler:
    def __init__(self, width: float, height: float, min_dist: float, k: int = 30):
        self.width = width
        self.height = height
        self.min_dist = min_dist
        self.k = k  # Candidates per point
        self.cell_size = min_dist / np.sqrt(2)
        self.grid_w = int(np.ceil(width / self.cell_size))
        self.grid_h = int(np.ceil(height / self.cell_size))
        self.grid = [[None] * self.grid_w for _ in range(self.grid_h)]
        self.points = []
        self.active = []
    
    def sample(self) -> list[Point]:
        # Start with a random initial point
        initial = Point(
            x=np.random.uniform(0, self.width),
            y=np.random.uniform(0, self.height)
        )
        self._add_point(initial)
        self.active.append(initial)
        
        while self.active:
            idx = np.random.randint(len(self.active))
            source = self.active[idx]
            found = False
            
            for _ in range(self.k):
                # Generate candidate in annulus around source
                angle = np.random.uniform(0, 2 * np.pi)
                dist = np.random.uniform(self.min_dist, 2 * self.min_dist)
                candidate = Point(
                    x=source.x + dist * np.cos(angle),
                    y=source.y + dist * np.sin(angle)
                )
                
                if self._is_valid(candidate):
                    self._add_point(candidate)
                    self.active.append(candidate)
                    found = True
                    break
            
            if not found:
                self.active.pop(idx)
        
        return self.points
    
    def _is_valid(self, point: Point) -> bool:
        if not (0 <= point.x < self.width and 0 <= point.y < self.height):
            return False
        
        gx = int(point.x / self.cell_size)
        gy = int(point.y / self.cell_size)
        
        for dy in range(-2, 3):
            for dx in range(-2, 3):
                nx, ny = gx + dx, gy + dy
                if 0 <= nx < self.grid_w and 0 <= ny < self.grid_h:
                    neighbor = self.grid[ny][nx]
                    if neighbor is not None:
                        dist = np.sqrt(
                            (point.x - neighbor.x) ** 2 + 
                            (point.y - neighbor.y) ** 2
                        )
                        if dist < self.min_dist:
                            return False
        return True
    
    def _add_point(self, point: Point):
        self.points.append(point)
        gx = int(point.x / self.cell_size)
        gy = int(point.y / self.cell_size)
        self.grid[gy][gx] = point

Density-Aware Placement

Sometimes you want varying density across the canvas rather than uniform distribution. I use a density map that controls how tightly elements can be packed in different regions.

class DensityAwarePlacer:
    def __init__(self, density_map: np.ndarray, base_min_dist: float):
        self.density_map = density_map  # 2D array, values 0-1
        self.base_min_dist = base_min_dist
        self.placed = []
    
    def get_local_min_dist(self, x: float, y: float) -> float:
        # Higher density = smaller minimum distance
        map_h, map_w = self.density_map.shape
        mx = int(x / self.canvas_w * map_w) % map_w
        my = int(y / self.canvas_h * map_h) % map_h
        density = self.density_map[my, mx]
        
        # Density of 1.0 = half the base distance
        # Density of 0.0 = double the base distance
        return self.base_min_dist * (2.0 - 1.5 * density)
    
    def try_place(self, x: float, y: float) -> bool:
        local_min = self.get_local_min_dist(x, y)
        
        for existing in self.placed:
            dist = np.sqrt((x - existing.x) ** 2 + (y - existing.y) ** 2)
            if dist < local_min:
                return False
        
        self.placed.append(Point(x, y))
        return True

Repulsion-Based Relaxation

For the most natural-looking distributions, I apply a physics-inspired relaxation step after initial placement. Points repel each other like charged particles, gradually settling into an equilibrium.

def relax_points(points: list[Point], iterations: int = 50, strength: float = 0.1) -> list[Point]:
    positions = np.array([[p.x, p.y] for p in points])
    
    for _ in range(iterations):
        forces = np.zeros_like(positions)
        
        for i in range(len(positions)):
            for j in range(i + 1, len(positions)):
                diff = positions[i] - positions[j]
                dist = np.linalg.norm(diff)
                if dist < 1e-6:
                    continue
                
                # Inverse square repulsion
                force = strength * diff / (dist ** 3)
                forces[i] += force
                forces[j] -= force
        
        positions += forces
    
    return [Point(x=p[0], y=p[1]) for p in positions]

Visual Results

The difference between random placement and anti-clustered placement is immediately visible. Random placement produces awkward clusters and empty voids. Poisson disk sampling creates a natural, balanced distribution that feels intentional without looking mechanical.

For my art system, combining Poisson disk sampling with density maps and a relaxation pass produces compositions that consistently look professionally designed. These heuristics are simple to implement but have an outsized impact on visual quality.