多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MATLAB三维泊松盘采样:生成带最小间距约束的随机点集

MATLAB三维泊松盘采样:生成带最小间距约束的随机点集 做三维点云相关课题时我经常需要先造一批带间距约束的随机点。比如模拟传感器采样位置、布置无人机航点、生成颗粒材料的初始分布这类需求背后都是同一个问题在 MATLAB 里随机生成 m 个三维坐标点同时保证任意两个点之间的距离不小于某个给定的 n。这事听起来简单真写起来坑不少直接rand(m,3)生成的话点与点之间经常挤成一团根本无法满足最小间距约束。这篇文章我会把这个问题完整拆开讲清楚几种可行思路的取舍、完整的 MATLAB 实现代码、以及怎么验证结果真的满足要求。适合正在做仿真布点、机器人路径规划、点云数据生成的同学参考也适合想搞懂“带约束随机采样”底层原理的人。1. 需求拆解为什么直接随机生成不行先说结论rand(m,3)只能保证每个点在三维空间内均匀分布不能保证点与点之间的距离。想象一下往一个房间里随机撒豆子总会有两颗豆子恰好落在同一块地板上这是概率问题不是运气问题。如果我们严格规定任意两点之间的距离不小于 n那么这个约束会带来两个直接影响第一随机点的“自由程度”被限制了。完全均匀随机生成的点要经过筛选才能满足约束所以最终结果不是纯粹的独立随机采样而是一种带排斥规则的随机采样这在数学上属于泊松盘采样Poisson Disk Sampling的一个变体。第二m 和 n 之间存在理论上的上限关系。如果把每个点想象成半径为 n/2 的互不相交小球那么这些小球的总体积不能超过立方体的体积。在单位立方体里粗略估算需要满足m * (4/3)π(n/2)^3 ≤ 1简化一下就是 m ≤ 6 / (π n^3)。这个公式虽然忽略了边界效应但用来预判一组参数能不能生成成功非常有效。比如 n 0.2 时理论上限大约为 m ≤ 238实际因为边界浪费能稳定生成的点往往远小于这个值。我见过不少同学一上来就写while循环反复随机生成结果跑了几分钟还在死循环就是没提前做这个估算。再一个容易被忽略的细节是这里说的“距离不小于 n”是任意两点之间都满足不是相邻两个点。有人会把点排序后只检查相邻点这完全不对。用pdist或者双重循环把所有点对距离都算一遍才是正确姿势后面我会专门讲验证部分。2. 方案对比三种实现思路怎么选针对这个带最小间距约束的三维随机布点问题常规做法有三类各有各的适用场景。2.1 暴力重试法思路最简单随机生成一个点检查它和所有已接受点的距离如果都大于等于 n 就保留否则丢弃重新生成。这个方案在点数量少、约束不太强的时候非常好用代码不过十几行逻辑也容易理解。但它有一个致命问题当 m 和 n 接近理论边界时拒绝率会急剧上升。单位立方体内n 0.3 时每个新点平均要尝试几十上百次才能通过生成 50 个点可能要算几秒钟。到了 n 0.4基本上已经逼近理论极限很多参数组合会直接卡死。所以我对暴力法的定位是快速验证代码逻辑、生成少量测试数据时用真正大量布点不推荐。2.2 网格加速泊松盘法这是我最推荐的做法也是很多图像处理、几何算法库里的标准方案。核心思想是用一个边长与 n 相关联的网格把空间切分成小格子每个格子最多放一个点。这样检查新点是否冲突时不用遍历所有已生成的点只需要检查它所在格子周围 3×3×3 27 个邻居格子里的点就行。为什么可以这样优化因为网格边长取 n/√3 时任意两个距离小于 n 的点必定落在相邻 27 个格子范围内。这个结论可以从三维空间的对角线关系推出来网格对角线长度恰好等于 n互相冲突的两个点不可能隔着两个甚至更多格子。于是全量 O(m²) 的距离检查就变成了近似 O(m) 的局部检查效率一下子提升了几个数量级。这个方法的另一个优势是生成的点分布更均匀。它在尝试填充空间而不是单纯躲避所以最终点集的整体观感好很多不会出现大片空洞。2.3 随机初始点加排斥迭代先随机生成 m 个点然后反复计算所有点对距离把距离小于 n 的点对沿连线方向推开。这个思路直观但实现起来要处理迭代次数、收敛判断、边界越界等一系列问题参数调不好很容易在局部震荡。除非有特殊需求我不建议在这个场景里用它后面就不展开了。3. 完整实现基于网格加速的泊松盘采样下面给出我在实际项目中用的实现已经打磨过很多次直接在脚本里复制就能跑。3.1 网格数据结构与初始化先说网格设计。假设整个采样空间是 [0,1]³ 的单位立方体最小间距为 n。网格边长取cellSize n / sqrt(3);这样每个格子的空间对角线长度恰好为 n保证冲突检查时只需要看周围 27 个格子。网格数量就是nx ceil(1 / cellSize); ny ceil(1 / cellSize); nz ceil(1 / cellSize);网格数量可能很大但没关系我们只需要用一个稀疏结构存每个格子里的点索引。MATLAB 里我一般用一个容器 Map 或者直接用三维 cell 数组来实现。如果点规模在十万以内三维 cell 数组的内存完全够用。初始化第一个点时直接在空间内均匀随机选一个位置计算它所属的网格索引存入网格中同时加入 active 列表。function points generatePoissonDisk3D(m, n, maxAttempts) % 在单位立方体内生成m个三维坐标点任意两点距离不小于n % 输入: m - 目标点数 % n - 最小间距 % maxAttempts - 每个active点最大尝试次数 % 输出: points - m x 3 数组每一行是一个坐标点 % % 参考经典Poisson Disk Sampling思路实现 if nargin 3 maxAttempts 30; end % 测试参数是否合理 maxPossible floor(6 / (pi * n^3)); if m maxPossible warning(理论上限约 %d 个点目标 %d 可能无法完成, maxPossible, m); end cellSize n / sqrt(3); nx ceil(1 / cellSize); ny ceil(1 / cellSize); nz ceil(1 / cellSize); % 使用cell数组作为网格容器每个网格存储一个点索引0表示空 grid cell(nx, ny, nz); % active列表存储当前还在尝试生成新点的点索引 activeList []; % 第一个点随机生成 firstPt rand(1,3); [ix, iy, iz] pointToGridIndex(firstPt, cellSize); grid{ix, iy, iz} 1; points firstPt; activeList 1; % 主循环 while ~isempty(activeList) size(points,1) m % 从active列表中随机选一个点 idx randi(length(activeList)); activeIdx activeList(idx); basePt points(activeIdx, :); found false; for attempt 1:maxAttempts % 在以basePt为中心半径[n, 2n]的球壳内生成候选点 r n * (1 rand()); theta 2 * pi * rand(); phi acos(2 * rand() - 1); candPt basePt r * [sin(phi)*cos(theta), sin(phi)*sin(theta), cos(phi)]; % 检查是否在边界内 if any(candPt 0) || any(candPt 1) continue; end % 计算候选点所在网格 [cxp, cyp, czp] pointToGridIndex(candPt, cellSize); % 检查相邻27个网格 conflict false; for dx -1:1 for dy -1:1 for dz -1:1 gx cxp dx; gy cyp dy; gz czp dz; if gx 1 || gy 1 || gz 1 || gx nx || gy ny || gz nz continue; end existingIdx grid{gx, gy, gz}; if ~isempty(existingIdx) diff points(existingIdx, :) - candPt; if dot(diff, diff) n^2 conflict true; break; end end end if conflict, break; end end if conflict, break; end end if ~conflict % 接受候选点 newIdx size(points,1) 1; points(newIdx, :) candPt; grid{cxp, cyp, czp} newIdx; activeList(end1) newIdx; found true; break; end end if ~found % 尝试多次都失败从active列表中移除当前点 activeList(idx) []; end end if size(points,1) m warning(只生成了 %d 个点目标 %d请尝试增大n或降低m, size(points,1), m); end end function [ix, iy, iz] pointToGridIndex(pt, cellSize) ix floor(pt(1) / cellSize) 1; iy floor(pt(2) / cellSize) 1; iz floor(pt(3) / cellSize) 1; end3.2 代码里的关键细节说明这段代码有几个地方值得单独拿出来讲因为正是这些细节决定了它能不能跑得又稳又快。候选点生成时我选择半径 r n * (1 rand())而不是固定在 n 到 2n 之间随机这样生成的点离基准点最近的可能是 n但绝不会小于 n减少无效尝试。角度采样用了球坐标系均匀采样phi acos(2*rand()-1)是为了让球面上的点按面积均匀分布而不是经度均匀这也是三维随机方向采样的标准做法。距离判断我用的是dot(diff, diff) n^2也就是比较距离平方和 n²。直接调sqrt虽然更直观但每判断一个候选点就要多一次开方运算在大量尝试时这些开销会被放大得很明显。平方比较在数学上完全等价但跑得快很多。active 列表的处理方式是经典的“减少无效计算”的设计。当某个点周围已经挤满了合格点时它再继续生成候选点只会不断失败所以直接把它从 active 列表中移除不再尝试。随着循环推进active 列表越来越短算法自然收敛。这里有一个小技巧代码里把activeList(idx) []在找到新点时没有执行所以不会在循环中改变索引逻辑是对的。配合这段代码的使用方式是这样n 0.15; m 100; points generatePoissonDisk3D(m, n); scatter3(points(:,1), points(:,2), points(:,3), 20, filled); axis equal;如果只是想生成 20 个点n 取 0.3 左右几乎瞬间就能完成。n 取 0.2 时生成 60 个点也很快基本在一两秒内。3.3 自定义区间把单位立方体扩展到任意长方体很多实际需求里点不一定落在单位立方体里可能要求落在长宽高不同的长方体区域甚至生成某个区间内的随机数。这个映射其实很简单先生成单位立方体内的点然后做坐标变换。假设目标区域由low [x1, y1, z1]和high [x2, y2, z2]定义那么每个点可以这样缩放range high - low; pointsReal points .* range low;这里要注意最小距离 n 也随缩放而变化。如果原来单位立方体的最小间距是 n映射到边长 range 的区域后实际最小间距会变成 n * range。所以如果希望目标区域内的最小距离仍保持 n需要先反向归一化再生成。举个例子目标区域是 [10, 20] × [0, 5] × [-3, 3]要求最小间距 n 1那么归一化到单位立方体时对应的 n 应该是 1 / min(range)然后再缩放回去。另外代码里判断边界时写的是any(candPt 0) || any(candPt 1)这套逻辑只适用于单位立方体。如果直接修改这段代码去支持任意区域其实只需要把边界判断改成candPt low || candPt high网格索引计算也要对应偏移。4. 验证与可视化怎么证明所有点间距都合格代码写完了运行也顺利但你不能光靠肉眼说“看起来均匀”。必须做数值验证这是写算法程序的基本素养。4.1 用 pdist 或矩阵计算最小距离最简单的验证手段是拿生成的点集合计算所有点对之间的距离。点数量在几千以内时直接用pdist最省事distMatrix pdist(points); minDist min(distMatrix); fprintf(最小点对距离: %.6f目标最小距离: %.6f\n, minDist, n);如果点数量上万pdist会生成一个巨大的向量内存可能吃不消这时候可以写一个分块检查或者用网格法在生成过程中直接打印最小冲突距离。不过绝大多数场景下pdist够用了。我自己还会习惯再算一个直方图看距离分布长什么样histogram(distMatrix, 50); xlabel(点对距离); ylabel(频数); title(所有点对距离分布); xline(n, r--, 最小间距 n);如果直方图在 n 左侧有一根高柱子说明有大量点对距离逼近 n 甚至小于 n那代码里肯定有 bug 或者参数设置不匹配。4.2 三维可视化检查数值通过以后再画一张三维散点图辅助判断分布形态figure; scatter3(points(:,1), points(:,2), points(:,3), 24, 1:size(points,1), filled); colorbar; axis equal; view(3);这里我给每个点赋了不同的颜色方便在视觉上追踪点的生成顺序。如果你用的是网格加速采样点通常是从某个位置向四周扩散开的颜色会有明显的层次感。如果点全部挤在角落那就是边界处理有问题如果中间出现一大片空洞说明 active 列表管理出了问题。4.3 定量评价均匀性除了最小距离还有一个指标可以反映布点质量最近邻距离的均值与方差。理想情况下最近邻距离应该集中在 n 附近方差小说明分布均匀。可以这样算[~, distToNearest] knnsearch(points, points, K, 2); nearestDist distToNearest(:, 2); fprintf(最近邻距离均值: %.4f标准差: %.4f\n, mean(nearestDist), std(nearestDist));这个指标在对比不同算法参数时很有用。比如maxAttempts从 10 提到 30最近邻均值可能会提高一点但生成时间也会上升可以用这个指标做权衡。5. 常见问题与避坑经验我把这几年用这套方法踩过的坑整理成一张速查表遇到问题可以直接对照排查。现象直接原因解决办法程序长时间卡住不结束m 和 n 的组合已经接近或超过理论可行域用 m ≤ 6/(π n³) 估算上限调大 n 或调小 m生成的点在局部区域扎堆边界范围没设置好或者 active 列表随机性不足检查边界判断确保候选点落在目标区域内适当调大 maxAttempts生成结果每次都不一样随机数种子没有固定在程序开头加rng(0)或者rng(shuffle)前者为了复现后者为了每次不同距离验证不通过网格尺寸写错导致漏检冲突确认cellSize n / sqrt(3)检查邻居网格范围是否覆盖全部 27 格点数量远小于 m 但还是报错空间太大点太密拒绝率很高检查理论可行域或者改用更大的采样空间生成的点明显偏向部分区域候选点半径范围太窄导致局部搜索把候选点半径范围改为[n, 2.5n]或者[n, 3n]增大探索范围程序运行速度极慢点数量只有几百双重循环暴力检查复杂度 O(m²)改用网格加速的邻居搜索见第 3 章代码5.1 关于 m 接近上限时的执行策略如果业务场景就是要求 m 尽量大比如在单位立方体里塞越多的点越好这时阈值 n 给得比较小生成过程会明显变慢。我有两个建议第一把maxAttempts适当从 30 提高到 50 甚至 100这会增加单点的尝试次数但换来的是更高的接受率整体上往往更快。第二不要上来就生成到 m可以先按 90% 的 m 生成看看耗时再决定要不要继续跑满。如果确实需要极限填充也可以换一种思路生成时先不考虑 m只给定 n让算法一直跑到 active 列表为空得到一个近似最大填充点集然后从里面随机抽取 m 个。这样做的好处是生成的每个点都是合法点抽出来的任意子集也满足距离约束而且整体分布更均匀。5.2 老版本 MATLAB 兼容性说明现在大部分实验室用的都是 2018 之后的版本rng、scatter3这些函数都没问题。但如果你手里的代码要发给用老版本 MATLAB比如 2014 之前的同事有几个函数需要注意rng在老版本里没有需要用rand(seed, 0)和randn(seed, 0)替代。knnsearch需要 Statistics and Machine Learning Toolbox如果没有这个工具箱验证最小距离时改用pdist或者sqrt(sum((points - points).^2, 3))。xline是老版本没有的绘图函数验证直方图里画竖线可以用line([n n], ylim)代替。5.3 性能实测参考我在一台普通办公电脑上测过一组数据生成 1000 个点n 0.1maxAttempts 30耗时大约 0.8 秒同样的参数生成 5000 个点耗时约 6 秒如果把 n 降到 0.055000 个点大约需要 15 秒。这中间的大部分时间都花在网格索引查找和候选点生成上而不是冲突检查本身说明这个算法在大规模布点时是可行的。如果点数量到了十万这个量级MATLAB 的内存和循环效率就会成为瓶颈。我的建议是要么用 C/MEX 重写核心循环要么改用意外的 Python numba 版本。不过在大多数课题和工程场景里几千到一万个点用 MATLAB 这套代码完全够用。5.4 一个小技巧用 hist 柱状图反推算法问题有一次我在验证生成结果时发现所有点对距离的直方图里在 n 附近有一根异常高的柱子而且 n 左侧几乎为零。后来排查发现我的maxAttempts设得太小导致算法经常在距离 n 边缘的位置接受新点结果很多点实际上挤在了一个很窄的环形区域里。把maxAttempts提高到 50 之后这个现象就消失了。如果你看到类似的现象可以优先检查尝试次数是否太少。6. 扩展思考这个采样方法还能用在哪儿聊完具体实现最后说一句题外话。这种带最小间距约束的采样方法其实就是泊松盘采样在三维空间的直接应用它生成的点集在很多领域都能用。做分子动力学模拟的人可能叫它“无重叠粒子初始位置生成”做机器人路径规划的人可能叫它“安全航点采样”做点云处理的人可能叫它“空间均匀降采样”本质上都是同一套东西。理解了这个算法的核心思想以后换什么语言、换什么平台都能轻松迁移过去。我个人在实际操作中最深的体会是不要迷信某一种固定的解法暴力重试法在小规模问题里简单可靠网格加速法在大规模问题里无可替代关键是根据自己的数据规模和约束强度做取舍。
返回列表