上个月接了一个小需求:用120×120的小方块随机拼出图形,再自动生成描边。听上去就是个像素级边缘检测的入门题,可真正把“随机”和“图形”这两个词放在一起时,问题就变得特别微妙——随机生成的往往是噪声而不是形状,描边描出来的也常常是一堆锯齿、断线和毛刺。
这篇文章把整个实现链路完整记录一遍,从数据表示、生成策略、轮廓提取到矢量化输出,全程使用Python + NumPy + OpenCV。适合正在做像素风生成、地图边界提取、贴纸轮廓、激光雕刻预处理,或者单纯想搞懂“如何把随机散点变成有意义的形状”的朋友参考。
1. 先拆需求:120x120方块拼图,到底“图形”和“描边”指什么
1.1 需求里的三个模糊点
这个标题看起来简短,但真正动手前必须先拆清楚三个问题,否则后面写代码一定会反复返工。
第一,“120×120的方块”到底指什么?是120像素×120像素的画布,还是120行×120列的逻辑网格?我最后选择了后者——用一个120×120的布尔矩阵作为核心数据结构,每个格子代表一个方块,值为1表示“这里被填充”,值为0表示“空白”。这样生成的形状天然具备网格感,渲染时再统一放大到实际尺寸。如果直接用14400×14400像素的位图去跑算法,内存和速度都不划算。
第二,“随机拼成图形”到底要多随机?纯随机撒点得到的是噪声,不是图形。标题里“拼成图形”这四个字暗示了一个隐含条件:结果必须看起来像“一块东西”,而不是雪花屏。所以生成阶段不能只用均匀分布,必须引入连通性约束,比如随机游走、区域生长这类算法。
第三,“描边”要的是哪一种边?如果只是给填充区域画一个虚线外框,那很简单,用形态学差分就能做。但如果要交付给激光雕刻机、切割机或者做贴纸轮廓,就要的是平滑的矢量轮廓线,得用轮廓追踪加多边形简化。
1.2 我最终圈定的技术边界
一句话总结我的方案:用120×120布尔矩阵保存图形,用随机游走加形态学处理生成“拼图”,用OpenCV的findContours提取外轮廓,再用approxPolyDP平滑轮廓,最后渲染成PNG并导出SVG。
选Python而不是Java或者其他语言,原因很直接:NumPy处理二维矩阵比手写数组方便一个量级,OpenCV的轮廓提取函数特别成熟,不需要我自己实现曲线追踪算法。如果你想在Java里复刻,思路完全一样,只是要自己找替代库比如BoofCV或OpenCV的Java绑定。
这个技术边界确定下来以后,整个项目就可以拆成两个独立阶段:先生成图形,再提取描边。这两个阶段彼此解耦,可以分别调试、分别替换算法,这也是我推荐的工作方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 随机图形生成:直接随机是噪声,引导随机才是图形
2.1 为什么纯随机生成不出“图形”
很多第一次做这个需求的人,第一版代码往往长这样:遍历120×120个格子,每个格子以某个概率p标记为填充,然后直接描边。
跑出来是什么效果呢?当p取0.1左右时,整个画面像是一张布满稀疏噪点的纸;当p取0.5时,更像一张半透明的磨砂玻璃。这些都不是“图形”。
原因在于均匀随机撒点只描述了“哪里有方块”,完全没有描述“方块之间的关联”。一个图形之所以成为图形,靠的是连通性:这块区域和那块区域是不是连着的,边缘是不是连续的,内部有没有孔洞。
所以第一步我就放弃了纯随机,转而用带路径约束的随机算法。
2.2 随机游走:最朴素也最出效果的生成方式
随机游走(Random Walk)是我在这个项目里最终采用的主生成算法,思路非常简单:从图形中心出发,每一步随机选择上下左右四个方向之一,走到一个新位置就标记该格子为填充。走完固定步数后,把走过的格子集合当作“图形”。
别小看这个朴素算法,它有一个其他方法很难替代的优点:生成的形状天然连续,而且边界带着一种手工绘制的随意感,很像那种被风吹出来的墨水渍。
核心代码只有十几行:
python复制import numpy as np
GRID_SIZE = 120
np.random.seed(42)
grid = np.zeros((GRID_SIZE, GRID_SIZE), dtype=np.uint8)
# 从中心出发
x, y = GRID_SIZE // 2, GRID_SIZE // 2
grid[y, x] = 1
# 随机游走步数
STEPS = 2000
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
for _ in range(STEPS):
dx, dy = directions[np.random.randint(4)]
nx, ny = x + dx, y + dy
# 越界保护:超出网格就放弃这一步
if 0 <= nx < GRID_SIZE and 0 <= ny < GRID_SIZE:
x, y = nx, ny
grid[y, x] = 1
2000步走下来,实际覆盖的格子数通常在600到1000之间,占整个120×120网格的4%到7%,视觉上是一个中等大小的不规则块状物。步数越少图案越纤细,步数越多图案越饱满,但超过5000步后基本会铺满整个网格,形状感反而下降。
这里面有个容易被忽略的细节:随机游走会产生大量“回头路”。因为每步方向完全随机,游走者很可能刚离开某个格子又被拉回去,所以看似走了2000步,唯一访问的格子可能只有600多个。这不是bug,反而是好事——重复访问会让路径交叉缠绕,形成更完整的团块。
游走结束后,图形往往带有大量细长的“毛刺”和单像素连接线。这一步先不做清理,留给后续形态学处理。
2.3 区域生长与参数化扰动:另外两种可选项
如果你的项目对形状风格有不同要求,还有两类生成方法值得放进备选清单。
区域生长(Region Growing)的玩法是:先在网格里撒几个种子点,然后不断从已有团块的边缘随机选一个格子,向它相邻的空白格扩展。这个算法生成的形状比随机游走更“实心”,内部不会有反复穿梭造成的交错纹路,边界更接近瓷砖拼接的像素块。
参数化扰动的方法更可控一些:先定义基础几何形状,比如圆形、三角形或者任意多边形,然后用随机函数对边界每个点做幅度限制的偏移。这类方法适合生成“大概像正方形但又不是正方形”的图形,可控性最强,代价是形状多样性不足。
我把这三种方案的特性做成了对比表,方便按项目需求直接选型:
| 生成方案 | 形状连贯性 | 多样性 | 实现难度 | 适用场景 |
|---|---|---|---|---|
| 纯随机撒点 | 极差 | 高 | 极低 | 噪声纹理,不适合当图形 |
| 随机游走 | 好 | 较高 | 低 | 有机感图形、墨水渍、岛屿轮廓 |
| 区域生长 | 好 | 中 | 中 | 实心像素块、瓷砖拼贴 |
| 参数化扰动 | 很好 | 中 | 中 | 需要“基本形状加变形”的图形 |
我最后选随机游走,除了实现快,还有一个关键原因:配合后续形态学膨胀处理,它生成的不规则边界在描边后最有“手工拼图”的视觉效果,不像区域生长那么方方正正。
3. 描边算法选型:从膨胀差分到轮廓追踪与简化
3.1 描边问题的本质
描边这件事,本质上是在回答一个问题:填充区域和空白区域的交界线在哪里。
在二值图像里,边界可以形式化地定义为“膨胀后的集合”减去“原始集合”。打个比方:把填充区域想象成一块橡皮泥,往所有方向均匀扩一圈,得到的外层橡皮泥就是描边的位置。这也正是图像形态学里膨胀运算的直观含义。
理解了这一点,就会发现描边并不需要“检测”什么边缘,只需要做一次集合运算。
3.2 形态学差分法:一眼看穿边界
第一种实现方式就是膨胀后与原图做差集。OpenCV里只有三行代码:
python复制import cv2
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
dilated = cv2.dilate(grid, kernel, iterations=1)
edge = cv2.subtract(dilated, grid)
dilated是膨胀后的图,grid是原图,两者相减后,留下来的像素就是原图形外侧的一圈。如果想实现“外描边”和“内描边”两种效果,只需要控制是做膨胀差集还是腐蚀差集:
python复制eroded = cv2.erode(grid, kernel, iterations=1)
inner_edge = cv2.subtract(grid, eroded)
形态学差分法的优点是快、直观、不需要理解任何几何算法。缺点也很明显:它输出的是像素级的锯齿线,每个拐角都会保留像素原本的台阶状结构。如果你要做激光切割,这种锯齿线会直接导致切割头来回抖动,切割边缘质量很差。
所以形态学差分法我只用在快速预览阶段,真正交付用的轮廓必须走轮廓追踪。
3.3 轮廓追踪与多边形简化
OpenCV的findContours是这类需求里最可靠的选择。它的工作原理可以理解为沿着白色区域和黑色区域的交界走一圈,把边界上的点按顺序记下来。相比形态学差分,它直接输出有序点集,这个有序性非常关键——后续做路径规划、曲线简化、数据导出全都依赖它。
以下是标准的轮廓提取代码:
python复制# 注意:findContours会修改输入图像,所以传入copy
contours, hierarchy = cv2.findContours(
grid.copy(),
cv2.RETR_EXTERNAL, # 只提取最外层轮廓
cv2.CHAIN_APPROX_SIMPLE # 压缩线段点,只保留拐点
)
这里有两个参数容易选错。
RETR_EXTERNAL的意思是“只给我最外面的那一圈轮廓”,如果图形内部有空洞,不会提取内边界。如果你希望同时描出内部孔洞的边,就需要改成RETR_CCOMP或者RETR_LIST。我做随机游走生成图形时基本不会产生大孔洞,所以用EXTERNAL就够了。
CHAIN_APPROX_SIMPLE则是把同一条直线上中间的点全部丢掉,只保留两个端点,让轮廓点数量大幅减小。别小看这个参数,直接决定后续导出SVG文件的大小。
轮廓提取之后,还要用approxPolyDP做一次多边形拟合。这个函数实现了经典的Douglas-Peucker算法,用一条线段去近似一串轮廓点,并计算这些点到线段的距离,超过阈值就继续细分,低于阈值就直接拉平:
python复制epsilon = 0.02 * cv2.arcLength(contour, True)
simplified = cv2.approxPolyDP(contour, epsilon, True)
epsilon通常取轮廓总周长的1%到2%。取太小,简化效果不明显,锯齿依旧存在;取太大,图形会明显失真,圆润的部分会被拉成多边形。我在随机游走生成的图形上测试,2%是一个安全起点。
4. 完整实现:Python + OpenCV 在120x120网格上跑通全流程
4.1 环境准备与数据表示
运行环境非常基础,只需要三个库:
bash复制pip install numpy opencv-python
所有图形数据都保存在一个120×120的uint8数组里,0表示空白,1表示填充。注意一定要用uint8,因为OpenCV的图像处理函数不接受bool数组。
这里有一个容易踩的误区:OpenCV的二值图像处理函数要求像素值只有0和255两种,如果你把1当作前景色传入cv2.dilate,虽然也能跑,但因为像素值太小,逻辑上并不会报错,却在后续查找轮廓时出现意外行为。稳妥做法是生成图形完成后统一转换:grid[grid > 0] = 255。
4.2 生成随机图形的完整代码
把随机游走、膨胀、去碎块三步串起来:
python复制import numpy as np
import cv2
GRID_SIZE = 120
SEED = 42
np.random.seed(SEED)
# ---------- 1. 随机游走生成原始图形 ----------
grid = np.zeros((GRID_SIZE, GRID_SIZE), dtype=np.uint8)
x, y = GRID_SIZE // 2, GRID_SIZE // 2
grid[y, x] = 1
STEPS = 2000
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
for _ in range(STEPS):
dx, dy = directions[np.random.randint(4)]
nx, ny = x + dx, y + dy
if 0 <= nx < GRID_SIZE and 0 <= ny < GRID_SIZE:
x, y = nx, ny
grid[y, x] = 1
# ---------- 2. 膨胀:把细线加宽成块状 ----------
kernel5 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.dilate(grid, kernel5, iterations=2)
# ---------- 3. 去碎块:面积小于阈值的连通域直接删除 ----------
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8)
MIN_AREA = 80
clean = np.zeros_like(mask)
for i in range(1, num_labels):
if stats[i, cv2.CC_STAT_AREA] >= MIN_AREA:
clean[labels == i] = 255
mask = clean
这里的三个参数值得细说。
膨胀核大小5×5、迭代两次,作用是把随机游走产生的单像素细线整体加粗到5像素宽左右。不是核越大越好,核太大时图形内部会糊成一团,丢失原有的路径交错感。
最小面积阈值80,用来过滤那些零零散散漂在外面的小碎块。随机游走偶尔会甩出去几段短路径,经过膨胀后变成独立的小岛,直接影响描边质量。
另外我建议把当前SEED值打印出来并记录在输出文件名里,比如pattern_{SEED}.png。这个习惯在批量调试参数时能救你的命——遇到一个理想图案,你可以随时复现;遇到一个怪图案,也能定位是不是种子问题。
4.3 描边与渲染的完整代码
生成图形后进入描边阶段,代码如下:
python复制# ---------- 4. 提取最外层轮廓 ----------
contours, hierarchy = cv2.findContours(
mask.copy(),
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)
# ---------- 5. 多边形简化 ----------
SCALE = 6 # 渲染放大倍数
canvas = np.full((GRID_SIZE * SCALE, GRID_SIZE * SCALE, 3), 255, dtype=np.uint8)
# 先画填充区域:浅色底
filled = np.repeat(np.repeat(mask, SCALE, axis=0), SCALE, axis=1)
canvas[filled > 0] = (230, 235, 245)
# 再逐个画出简化后的轮廓
for contour in contours:
epsilon = 0.02 * cv2.arcLength(contour, True)
approx = cv2.approxPolyDP(contour, epsilon, True)
pts = approx.reshape(-1, 2) * SCALE
cv2.polylines(canvas, [pts], isClosed=True, color=(30, 40, 80), thickness=3, lineType=cv2.LINE_AA)
cv2.imwrite("pattern_outline.png", canvas)
这里有一个渲染细节:canvas放大用的是np.repeat而不是cv2.resize。repeat是纯粹的最近邻放大,每个逻辑网格变成SCALE×SCALE的像素块,边缘保持绝对硬朗。cv2.resize默认用线性插值,会把边缘变成渐变的灰阶过渡,对描边类渲染效果非常不友好。
轮廓点坐标乘以SCALE后,画出来的线刚好落在放大后的填充区域与空白区的交界带上,视觉上相当于“骑在边界上”。如果想让描边完全在填充区外侧,可以对轮廓点再做一次偏移,不过实际应用里骑线描边已经很好看了。
4.4 渲染效果与实际输出
跑完上面这段代码,你会得到一张类似这样的图:底图是白底,填充区域是带一点点蓝调的浅灰色,轮廓是深蓝色的连续线条。因为用了approxPolyDP简化,轮廓上的锯齿被压缩成了少量折线段,在视觉上保持像素拼贴感的同时又比裸像素更利落。
如果你想确认每一步算法到底做了什么,可以把中间态都保存下来:原始随机游走路径、膨胀后的mask、过滤碎块后的mask、最终轮廓图。我在实际调试中就是靠这四张图对齐问题,别嫌麻烦,这个习惯能替你节省大量时间。
5. 实测中的坑与调优:锯齿、断边和毛刺怎么处理
5.1 坑一:随机游走产生一维细线,轮廓莫名断裂
随机游走生成的原始路径里,有很多“单像素桥”:两个团块之间只靠一条细线相连。膨胀之后细线会变粗,看起来是连上了,但如果膨胀力度不够,这条桥依然是全场最窄的地方。提取轮廓时,这段桥会形成非常尖锐的凹口,approxPolyDP在简化时很可能把凹口拉平,导致视觉上出现“轮廓穿过空白区域”的怪相。
解决办法有两个方向:一是增加膨胀迭代次数,保证最细的连接处至少有5像素宽;二是用形态学闭运算(先膨胀后腐蚀)把桥接处磨圆:
python复制kernel3 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel3, iterations=2)
闭运算的作用是把相邻团块之间的凹缝填平,填完之后再提取轮廓,断边和尖坑的问题会显著减少。
5.2 坑二:单像素格子与Resize插值导致边缘发虚
我最早一版直接用一个120×120的小画布生成描边,然后用cv2.resize放大到720×720。结果显示所有边缘都是灰蒙蒙的,轮廓线又粗又虚。
原因很简单:cv2.resize默认的插值算法会在黑白交界处生成过渡像素,原本锐利的边缘被强行“柔化”。对照片缩放这是好功能,对像素拼图这完全是个灾难。
解决方案就是前面提到的np.repeat,或者cv2.resize时显式指定interpolation=cv2.INTER_NEAREST。这一改,边缘立刻恢复硬边效果。如果你做的是类似棋盘格、像素风地图这类内容,记住这个细节能少摔一次。
5.3 坑三:4邻域和8邻域决定“粘连”与“分离”
在判断哪些格子属于同一个团块时,OpenCV的connectedComponentsWithStats有一个邻域参数。默认是8邻域,也就是对角线接触也算同一个连通域。而4邻域只考虑上下左右。
这两种模式会影响什么?举个例子:两块区域只有一个角上的格子相接。在8邻域下它们是同一块,外轮廓会绕过整个外部;在4邻域下它们各自独立,会提取出两条轮廓。
做随机游走图形时,路径交叉产生的“角对角”情况非常常见。我自己最后统一用8邻域,因为视觉上角对角看起来就是连在一起的,符合人对图形的直觉。但这没有绝对标准——如果你是想让图形内部保留更多独立性,果断用4邻域。
5.4 坑四:随机种子的复现与参数调试
随机游走算法对种子极其敏感。同一套参数换一个种子,生成的图形可能从胖墩墩的团块变成细长的触手。这既是优点也是坑。
调试时的建议是:固定一组种子,比如42、2024、8888,先用小步数快速跑通流程,确认描边和渲染没有问题,再批量切换种子收集不同形状。别在没调通描边时就用100个种子批量跑,否则你会看到100种奇怪的断边和毛刺。
另一个实用技巧是把种子和生成参数一起编码到文件名里,比如walk_2000_dilate5_2_seed42.png。这样后期回溯参数组时,看一眼文件就知道当前图是怎么来的。
6. 描边结果的落地:从像素轮廓到可交付的矢量数据
6.1 应用场景:贴纸、雕刻、游戏地图
描边结果最直接的价值就是“可制造性”。如果你做的是贴纸切割,需要的就是一条闭合的、不自交的、平滑的轮廓路径,这台切割机才能看懂;如果你做的是激光雕刻,同样需要闭合矢量路径,机器才能沿着路径走刀。
像素网格图本身是没法直接发给设备的,但描边后的轮廓点集可以。
我建议把轮廓点集输出成两种格式:一种是通用的JSON点序列,方便程序进一步处理;另一种是SVG矢量图,方便交付给设计师和机器。
6.2 从OpenCV轮廓导出SVG
SVG的path路径语法很简单:M表示移动到起点,L表示画直线到某个点,Z表示闭合路径。把approxPolyDP得到的点集写入SVG即可:
python复制def export_svg(contours, filename, width=720, height=720):
paths = []
for contour in contours:
approx = cv2.approxPolyDP(contour, 0.02 * cv2.arcLength(contour, True), True)
pts = approx.reshape(-1, 2) * 6 # 放大6倍
if len(pts) < 3:
continue
d = f"M {pts[0][0]} {pts[0][1]} "
d += " L ".join([f"{x} {y}" for x, y in pts[1:]])
d += " Z"
paths.append(d)
svg = (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
f'viewBox="0 0 {width} {height}">\n'
f'<path d="{" ".join(paths)}" fill="#e6ecf5" stroke="#1e2850" '
f'stroke-width="3" stroke-linejoin="round"/>\n</svg>'
)
with open(filename, "w", encoding="utf-8") as f:
f.write(svg)
SVG里有个容易被忽略的属性stroke-linejoin="round",加了它之后折线拐角处会变成圆弧过渡,也就是俗称的“圆角效果”,切割机走刀更顺,视觉上也比默认的尖角更友好。
6.3 可以继续扩展的三个方向
这个项目做完之后,我给自己列了几个扩展方向,也分享给你作为下一步参考。
第一,形状对称增强。随机游走路径在对称轴上做镜像复制,就能生成像蝴蝶、树叶这类对称图案,应用场景比随机图形广得多。
第二,多边形平滑处理。approxPolyDP输出的是折线段,如果想要真正圆润的贝塞尔曲线,可以再用样条拟合算法,比如Chaikin曲线细分或Catmull-Rom样条。这样描边会更加丝滑,代价是输出格式从多边形变成曲线参数。
第三,批量生成数据集。把120×120网格的填充图、描边轮廓、SVG文件三件套批量生成后,可以用来训练图形识别模型、做游戏关卡自动生成,或者单纯当一个“随机logo素材库”用。
回到最初的问题——根据120×120小块随机拼图生成描边,这个需求真正的门槛不在于描边,而在于怎么让随机结果看起来像图形。我个人踩过几轮之后的体会是:先把图形生成算法做扎实,描边只是水到渠成的一步。用随机游走配合膨胀和去碎块,再交给findContours和approxPolyDP收尾,这套组合在可控性、效果和代码量之间找到了一个很舒服的平衡点。你可以直接照着跑,也可以把随机游走换成区域生长或参数化扰动,整个描边链路完全不用改。
