高效生成均匀分布的点:快速泊松碟采样算法实现(Fast Poisson Disc Sampling)

快速泊松碟采样算法
本文介绍了一种用于生成均匀分布点集的快速泊松碟采样算法,该算法能够在二维平面上生成满足特定距离约束的随机点,且算法复杂度为O(N)。通过合理设置参数,算法能确保点集分布均匀且随机,适用于Voronoi图生成等应用。

快速泊松碟采样算法实现(Fast Poisson Disc Sampling)

前(fei)言(hua)

最近在看一些随机地图生成算法,涉及到生成Voronoi图,这需要提前在一个平面内随机生成一堆的点,这些点还要满足随机而且尽量均匀分布在平面上。一般文章都提到采用Lloyd Relaxation算法,不过这个算法比较复杂,消耗也比较大,后来看到这个快速泊松碟采样算法,也是用于生成一堆均匀分布的点的,而且算法复杂度在 O ( N ) O(N) O(N)
算法的说明论文可以参考这里。个人觉得描述得很不(fan)清(ren)晰(lei),后来我看了别人的视频说明才真正了解这个算法,其实算法本身很简单,也很直观,下面会讲解下。完整代码我放在个人Github上。

算法说明

下面以2维平面为例:
假设我们需要在一个宽高为 ( w i d t h , h e i g h t ) (width,height) (width,height)的平面内平均生成一堆的点,且这些点之间的距离不能小于 r r r
我们可以先从平面内随机选一个点,然后在这个点附近随机找一些点,并判断这些点是否合法,合法的话则在这些点附近继续随机寻找,直到找不到合法点为止。

算法简单说起来就是这样,下面详细说下细节。

  1. 为了保证能尽量填满整个平面, 随机找点时,采用与中心点距离为 [ r , 2 r ) [r,2r) [r,2r)的圆环内找,这个距离能保证找的点距离自身大于 r r r,且不会离得太远,能填满整个平面。
  2. 怎么判断一个点的附近已经找不到合法点了?算法定义了一个常量 k k k,对于每个点,我们尝试在它附近随机找 k k k次,如果都找不到,那么就认为这个点附近已经没有合法点。
  3. 怎么快速判定随机找出的点是否合法?这个是算法的关键,可以采用一些空间划分方法来做(游戏场景也会经常用到),首先将平面划分成 m m m n n n列的格子,每个格子都保存了格子内部的点。这样当我需要判断一个点是否合法时,我只要和附近的格子内的点做判断即可。
  4. 那怎么确定每个格子的大小?我们尽量让每个格子内部最多只能有1个点,这样数据结构就会简单很多。怎么做到呢?我们假设每个格子都是正方形,那正方形内部距离最远的点就是对角线的2个点,所以我们只要保证正方形的对角线长度大于等于 r r r,则正方形内部任意2个点之间的距离肯定小于 r r r,从而保证每个正方形内部肯定最多只能有1个点。假设正方形边长为 a a a,对角线长度为 r r r,那么有: a 2 + a 2 = r 2 a^2+a^2=r^2 a2+a2=r2,那么 a = r 2 a=\frac{r}{\sqrt{2}} a=2 r

代码

public static class Algorithm
{
    public static List<Vector2> Sample2D(float width, float height, float r, int k = 30)
    {
        return Sample2D((int)DateTime.Now.Ticks, width, height, r, k);
    }

    public static List<Vector2> Sample2D(int seed, float width, float height, float r, int k = 30)
    {
        // STEP 0

        // 维度,平面就是2维
        var n = 2;

        // 计算出合理的cell大小
        // cell是一个正方形,为了保证每个cell内部不可能出现多个点,那么cell内的任意点最远距离不能大于r
        // 因为cell内最长的距离是对角线,假设对角线长度是r,那边长就是下面的cell_size
        var cell_size = r / Math.Sqrt(n);

        // 计算出有多少行列的cell
        var cols = (int)Math.Ceiling(width / cell_size);
        var rows = (int)Math.Ceiling(height / cell_size);

        // cells记录了所有合法的点
        var cells = new List<Vector2>();

        // grids记录了每个cell内的点在cells里的索引,-1表示没有点
        var grids = new int[rows, cols];
        for (var i = 0; i < rows; ++i) {
            for (var j = 0; j < cols; ++j) {
                grids[i, j] = -1;
            }
        }

        // STEP 1
        var random = new Random(seed);

        // 随机选一个起始点
        var x0 = new Vector2(random.Range(width), random.Range(height));
        var col = (int)Math.Floor(x0.x / cell_size);
        var row = (int)Math.Floor(x0.y / cell_size);

        var x0_idx = cells.Count;
        cells.Add(x0);
        grids[row, col] = x0_idx;

        var active_list = new List<int>();
        active_list.Add(x0_idx);

        // STEP 2
        while (active_list.Count > 0) {
            // 随机选一个待处理的点xi
            var xi_idx = active_list[random.Range(active_list.Count)]; // 区间是[0,1),不用担心溢出。
            var xi = cells[xi_idx];
            var found = false;

            // 以xi为中点,随机找与xi距离在[r,2r)的点xk,并判断该点的合法性
            // 重复k次,如果都找不到,则把xi从active_list中去掉,认为xi附近已经没有合法点了
            for (var i = 0; i < k; ++i) {
                var dir = random.insideUnitCircle();
                var xk = xi + (dir.normalized * r + dir * r); // [r,2r)
                if (xk.x < 0 || xk.x >= width || xk.y < 0 || xk.y >= height) {
                    continue;
                }

                col = (int)Math.Floor(xk.x / cell_size);
                row = (int)Math.Floor(xk.y / cell_size);

                if (grids[row, col] != -1) {
                    continue;
                }

                // 要判断xk的合法性,就是要判断附近有没有点与xk的距离小于r
                // 由于cell的边长小于r,所以只测试xk所在的cell的九宫格是不够的(考虑xk正好处于cell的边缘的情况)
                // 正确做法是以xk为中心,做一个边长为2r的正方形,测试这个正方形覆盖到的所有cell
                var ok = true;
                var min_r = (int)Math.Floor((xk.y - r) / cell_size);
                var max_r = (int)Math.Floor((xk.y + r) / cell_size);
                var min_c = (int)Math.Floor((xk.x - r) / cell_size);
                var max_c = (int)Math.Floor((xk.x + r) / cell_size);
                for (var or = min_r; or <= max_r; ++or) {
                    if (or < 0 || or >= rows) {
                        continue;
                    }

                    for (var oc = min_c; oc <= max_c; ++oc) {
                        if (oc < 0 || oc >= cols) {
                            continue;
                        }

                        var xj_idx = grids[or, oc];
                        if (xj_idx != -1) {
                            var xj = cells[xj_idx];
                            var dist = (xj - xk).magnitude;
                            if (dist < r) {
                                ok = false;
                                goto end_of_distance_check;
                            }
                        }
                    }
                }

                end_of_distance_check:
                if (ok) {
                    var xk_idx = cells.Count;
                    cells.Add(xk);

                    grids[row, col] = xk_idx;
                    active_list.Add(xk_idx);

                    found = true;
                    break;
                }
            }

            if (!found) {
                active_list.Remove(xi_idx);
            }
        }

        return cells;
    }
}
// 测试代码
class Program
{
    static void Main(string[] args)
    {
        var width = 1024;
        var height = 1024;
        var r = 50f;
        var points = Algorithm.Sample2D(width, height, r);

        var image = new Bitmap(width, height);
        using (var graphics = Graphics.FromImage(image)) {
            graphics.FillRectangle(Brushes.Black, 0f, 0f, width, height);

            var dot_r = 3f;
            var pen = new Pen(Color.DarkRed, 2f);
            foreach (var p in points) {
                graphics.FillEllipse(Brushes.Yellow, p.x - dot_r, p.y - dot_r, 2f * dot_r, 2f * dot_r);
                graphics.DrawEllipse(pen, p.x - r / 2f, p.y - r / 2f, r, r);
            }
        }

        image.Save("out.png");
    }
}

输出的图片:可以看到黄点分布随机且比较平均,且任意2个黄点之间的距离都小于 r r r(红色圆的半径是 r / 2 r/2 r/2
在这里插入图片描述

import numpy as np import ezdxf import random from scipy.spatial import cKDTree from tqdm import tqdm import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import time import math def optimized_poisson_disc_sampling(width, height, r, k=50, max_attempts=5000): """ 优化的圆盘采样算法,提高密度 :param width: 区域宽度 :param height: 区域高度 :param r: 最小间距 :param k: 每个活跃尝试生成的次数 :param max_attempts: 最大尝试次数 :return: 采样列表 """ # 计算背景网格的单元大小 cell_size = r / np.sqrt(2) grid_width = int(np.ceil(width / cell_size)) grid_height = int(np.ceil(height / cell_size)) grid = [[None] * grid_width for _ in range(grid_height)] # 生成多个初始 active_list = [] samples = [] # 初始数量基于区域面积 initial_points = max(10, int((width * height) / (r * r * 0.8))) for _ in range(initial_points): x = width * random.random() y = height * random.random() point = (x, y) grid_row = min(int(y // cell_size), grid_height - 1) grid_col = min(int(x // cell_size), grid_width - 1) if grid[grid_row][grid_col] is None: grid[grid_row][grid_col] = point active_list.append(point) samples.append(point) # 辅助函数:计算两之间的距离 def distance(p1, p2): return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) # 辅助函数:检查新是否在区域内且与已有保持最小距离 def is_valid(point): # 检查是否在区域内 if not (0 <= point[0] < width and 0 <= point[1] < height): return False # 计算网格坐标 grid_row = min(int(point[1] // cell_size), grid_height - 1) grid_col = min(int(point[0] // cell_size), grid_width - 1) # 检查周围5x5网格 row_start = max(0, grid_row - 2) row_end = min(grid_height, grid_row + 3) col_start = max(0, grid_col - 2) col_end = min(grid_width, grid_col + 3) for r in range(row_start, row_end): for c in range(col_start, col_end): neighbor = grid[r][c] if neighbor is not None: if distance(point, neighbor) < r: return False return True # 主循环 attempts = 0 while active_list and attempts < max_attempts: attempts += 1 # 随机选择一个活跃 active_index = random.randint(0, len(active_list) - 1) active_point = active_list[active_index] found = False for _ in range(k): # 在活跃周围生成(环形区域,内径r,外径2r) angle = 2 * math.pi * random.random() radius = r + r * random.random() new_x = active_point[0] + radius * math.cos(angle) new_y = active_point[1] + radius * math.sin(angle) new_point = (new_x, new_y) if is_valid(new_point): grid_row = min(int(new_y // cell_size), grid_height - 1) grid_col = min(int(new_x // cell_size), grid_width - 1) # 确保网格位置未被占用 if grid[grid_row][grid_col] is None: active_list.append(new_point) samples.append(new_point) grid[grid_row][grid_col] = new_point found = True break if not found: # 移除活跃 active_list.pop(active_index) print(f"采样完成,生成 {len(samples)} 个,尝试次数: {attempts}") return np.array(samples) def optimized_diameter_assignment(points, min_diameter, max_diameter, min_distance): """ 优化的直径分配策略,提高效率和准确性 """ # 确保集是二维数组 if len(points) == 0: return np.array([]), [] if not isinstance(points, np.ndarray) or points.ndim != 2 or points.shape[1] != 2: points = np.array(points).reshape(-1, 2) n = len(points) if n == 0: return np.array([]), [] # 创建KDTree用于快速邻居查询 tree = cKDTree(points) # 计算每个的密度(邻居数量) density_map = np.zeros(n) for i in range(n): density_map[i] = len(tree.query_ball_point(points[i], min_distance * 2.0)) # 直径组定义 size_groups = { 'small': min_diameter + 0.2 * (max_diameter - min_diameter), 'medium': min_diameter + 0.5 * (max_diameter - min_diameter), 'large': min_diameter + 0.8 * (max_diameter - min_diameter) } # 目标分布比例(基于密度调整) group_distribution = { 'small': 0.3, 'medium': 0.4, 'large': 0.3 } # 根据密度排序(低密度区优先分配大直径) density_rank = np.argsort(density_map) # 计算每组分配的数 large_count = max(1, int(n * group_distribution['large'])) medium_count = max(1, int(n * group_distribution['medium'])) small_count = n - large_count - medium_count # 初始化直径和分组 diameters = np.zeros(n) group_assignments = [''] * n # 分配大直径组(低密度区域) for i in range(large_count): idx = density_rank[i] group_assignments[idx] = 'large' diameters[idx] = random.uniform( size_groups['large'] - 0.05 * (max_diameter - min_diameter), size_groups['large'] + 0.05 * (max_diameter - min_diameter) ) # 分配中直径组 for i in range(large_count, large_count + medium_count): idx = density_rank[i] group_assignments[idx] = 'medium' diameters[idx] = random.uniform( size_groups['medium'] - 0.08 * (max_diameter - min_diameter), size_groups['medium'] + 0.08 * (max_diameter - min_diameter) ) # 分配小直径组(高密度区域) for i in range(large_count + medium_count, n): idx = density_rank[i] group_assignments[idx] = 'small' diameters[idx] = random.uniform( min_diameter, size_groups['small'] + 0.05 * (max_diameter - min_diameter) ) # 冲突解决策略 print("开始冲突检测与解决...") max_iterations = 5 for iteration in range(max_iterations): conflict_count = 0 conflict_pairs = set() # 使用KD树查找所有可能冲突 max_radius = max_diameter / 2 neighbors = tree.query_ball_tree(tree, max_radius * 2.0) for i in range(n): for j in neighbors[i]: if i >= j: continue dist = np.linalg.norm(points[i] - points[j]) radius_sum = diameters[i] / 2 + diameters[j] / 2 if dist < radius_sum: conflict_pairs.add((i, j)) conflict_count += 1 print(f"迭代 {iteration + 1}: 检测到 {conflict_count} 个冲突") if conflict_count == 0: break # 解决冲突:优先缩小大直径的圆 for i, j in conflict_pairs: if group_assignments[i] == 'large' and group_assignments[j] != 'large': # 优先缩小大直径的圆 required_radius_sum = dist * 0.95 reduction_ratio = required_radius_sum / radius_sum # 缩小大直径圆 new_diameter_i = max(min_diameter, diameters[i] * reduction_ratio) diameters[i] = new_diameter_i else: # 平均缩小两个圆 required_radius_sum = dist * 0.95 reduction_ratio = required_radius_sum / radius_sum # 等比例缩小两个圆 new_diameter_i = max(min_diameter, diameters[i] * reduction_ratio) new_diameter_j = max(min_diameter, diameters[j] * reduction_ratio) diameters[i] = new_diameter_i diameters[j] = new_diameter_j return diameters, group_assignments def visualize_point_distribution(points, diameters=None): """可视化分布情况""" plt.figure(figsize=(10, 10)) if diameters is None: # 绘制 plt.scatter(points[:, 0], points[:, 1], s=2, c='blue') plt.title(f'Point Distribution: {len(points)} points') else: # 绘制带直径的圆 for i in range(len(points)): circle = plt.Circle( (points[i, 0], points[i, 1]), diameters[i] / 2, color='blue', fill=False, alpha=0.5 ) plt.gca().add_patch(circle) plt.title(f'Circle Distribution: {len(points)} circles') plt.xlim(0, width) plt.ylim(0, height) plt.gca().set_aspect('equal', adjustable='box') plt.savefig('point_distribution.png', dpi=300) plt.close() print("分布可视化已保存为 'point_distribution.png'") # 参数配置 width, height = 50, 50 # 单位:毫米 min_distance = 0.020 # 减小最小间距以增加密度 diameter_range = (0.010, 0.015) # 扩大直径范围 # 生成 print("开始优化圆盘采样...") start_time = time.time() points = optimized_poisson_disc_sampling(width, height, min_distance, k=50, max_attempts=5000) print(f"采样完成,生成 {len(points)} 个,耗时 {time.time() - start_time:.2f} 秒") if len(points) == 0: print("错误:圆盘采样生成任何") # 尝试进一步减小最小间距 min_distance = 0.010 points = optimized_poisson_disc_sampling(width, height, min_distance, k=60, max_attempts=6000) print(f"重新采样生成 {len(points)} 个") if len(points) == 0: raise RuntimeError("无法生成集,请检查参数设置") # 可视化分布 visualize_point_distribution(points) # 直径分配 print("开始优化直径分配...") diameters, groups = optimized_diameter_assignment( points, diameter_range[0], diameter_range[1], min_distance ) # 可视化带直径的分布 visualize_point_distribution(points, diameters) # 分组统计 group_counts = {'small': 0, 'medium': 0, 'large': 0} for g in groups: group_counts[g] += 1 print("\n直径组分布统计:") for group, count in group_counts.items(): if count == 0: print(f"{group.capitalize()}组: 0个") continue group_diameters = [d for d, g in zip(diameters, groups) if g == group] min_d = min(group_diameters) max_d = max(group_diameters) avg_d = np.mean(group_diameters) print(f"{group.capitalize()}组 ({count}个): 直径范围 {min_d:.5f}-{max_d:.5f}, 平均 {avg_d:.5f}") # 最终检查交叉 if len(points) > 0: tree = cKDTree(points) violation_count = 0 # 仅检查近距离对 neighbors = tree.query_ball_tree(tree, max(diameters)) for i in range(len(points)): for j in neighbors[i]: if i >= j: continue dist = np.linalg.norm(points[i] - points[j]) radius_sum = diameters[i] / 2 + diameters[j] / 2 if dist < radius_sum: violation_count += 1 print( f"交叉警告: {i}(直径{diameters[i]:.5f}) {j}(直径{diameters[j]:.5f}) | 距离={dist:.5f}, 半径和={radius_sum:.5f}") if violation_count == 0: print("✅ 所有圆均已通过无交叉验证") else: print(f"⚠️ 检测到 {violation_count}处潜在交叉区域") else: print("没有可检查交叉") # 创建DXF文档 if len(points) > 0: doc = ezdxf.new('R2010') msp = doc.modelspace() group_colors = { 'small': 2, # 黄色 'medium': 3, # 绿色 'large': 1 # 红色 } for i in tqdm(range(len(points)), desc="生成圆"): point = points[i] d = diameters[i] g = groups[i] circle = msp.add_circle(center=(point[0], point[1]), radius=d / 2) circle.dxf.color = group_colors[g] # 保存DXF文件 output_file = f'diameter_optimized_{len(points)}_circles.dxf' doc.saveas(output_file) print(f"DXF文件已保存: {output_file}") print(f"实际直径范围: {min(diameters):.5f} - {max(diameters):.5f}") else: print("没有生成DXF文件")这段代码生成出来的圆有很多重叠的现象,帮我修改代码,解决此问题哦
06-14
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值