☰
矩形排样遗传算法实战:BL解码、切割间距与可视化调参
2026/10/26 9:01:08 网站建设 项目流程

简介:这份资源面向学习智能优化算法与工业排样设计的开发者,聚焦矩形排样(排料、切割)优化问题,通过遗传算法在大矩形母材内无重叠地摆放尺寸各异的矩形,以提升空间与材料利用率,适合课程设计、毕业设计和算法入门实践。压缩包约22.19MB,共65个文件,主体为4个cpp与4个h源文件、6个obj及pdb、ilk等编译中间产物,另有exe可执行程序、ico图标与res资源文件、manifest清单和多个txt数据样本及htm说明,工程结构与调试产物保存完整,可直接运行或继续改造。资源已有4028人学习下载,说明其在排样类课题中具备较高参考价值。读者能获得一套可编译的遗传算法排样工程,其中包含种群生成、适应度评估、选择交叉变异等核心流程的代码实现,也可借助配套可视化界面观察每轮迭代的布局变化与材料利用率,理解算法收敛过程,并可替换txt数据文件测试自定方案,对裁剪、包装、印刷等场景的排样建模与调参有直接借鉴意义。

1. 从一张板的余料说起:矩形排样到底在算什么

矩形排样、矩形排料、矩形切割这几个词在车间和代码里经常混着用,落到实现上都是二维矩形装箱:给定板材和零件清单,决定每块零件放在哪、要不要旋转、用几张板。遗传算法在这里不直接画坐标,它负责给出零件进入排样器的顺序,真正决定利用率的是解码放置规则和间距校验。这份资源把算法和两套可视化界面放在一起,一套用来调参数看迭代,一套用来看排样图、利用率台账和坐标表,适合做板材下料、切割排料、课程设计原型的人直接拆改。

一个反直觉的结论先放在这里:排样图难看,多半不是种群规模不够大,而是解码器把间距、边距和旋转后的宽高弄错了。先把放置规则和校验写对,再去调交叉变异,GA 才有意义。资源里的两个界面正好把“调参”和“验收”分开,少了很多边调边看的混乱。

2. 遗传算法落到排样:排列编码、BL 解码与适应度怎么定

2.1 排列编码与放置解码:为什么不是直接画坐标

矩形排样问题最直接的冲动是把每块零件的 x、y、旋转角直接塞进染色体,让遗传算法去搜坐标。这个做法在小规模数据上能跑,但零件一多就会崩:坐标是连续量,交叉之后大量个体越界或重叠,适应度地形非常碎,调参基本靠运气。更稳的矩形算法是把问题拆成两层,遗传算法只负责“顺序”,几何放置交给确定性的解码器。染色体就是一个零件索引的排列,比如[3, 0, 7, 2, ...],表示排样器按这个顺序依次尝试放零件。

解码器我一般用 BL 放置,也就是 Bottom-Left:每放一块零件,候选左下角从板材边距和已放矩形的右边缘、上边缘生成,在所有可行位置里选 y 最小、再选 x 最小的那个。每块零件尝试两个方向:原始宽高和旋转 90 度后的宽高。边界检查用x + w + margin <= board_w,矩形之间用kerf做间距。这个规则不保证全局最优,但稳定、可复现,而且和切割现场“从左下往右上排”的直觉一致。排列编码加上 BL 解码,交叉变异之后仍然能生成合法或接近合法的排样,比直接搜坐标靠谱得多。

这里有个容易翻车的细节:间距kerf到底加在零件尺寸上,还是加在占位框上。常见做法是放置时用外扩后的占位框做重叠判断,绘制时还原成零件实际宽高,利用率按实际面积算。这样刀缝不进入零件面积,但会占用板材空间,账才对得上。下面的解码代码按这个口径写,整数坐标,避免浮点比较带来的玄学重叠。

2.2 适应度函数:利用率、余料块与惩罚项

单板排样的适应度可以很直接:已放置零件的实际面积除以板材面积。但只写这一项会出问题,因为解码器放不下时可能返回不完整结果,遗传算法会偏爱“少放几块但利用率高”的个体。我通常把未放置零件数量做成惩罚项,适应度写成placed_area / board_area - penalty * unplaced_count,惩罚系数不用太大,能压住不可行解就行。多板排料则换成total_part_area / (used_boards * board_area),再对板数做一点惩罚,避免为了塞满最后一张板把前面几张排得很碎。

利用率的口径要提前定死。板材面积是内尺寸减掉边距后的面积,还是整张板面积?kerf占用的空间算不算浪费?余料块要不要单独统计最大可回收矩形?这些不在代码里统一,界面上的 92% 和车间算出来的 88% 就会打架。我的习惯是界面台账里同时给出“零件净面积”“占位面积”“边距和刀缝消耗”“余料面积”,四个数能对上,再谈优化。只盯一个利用率数字,很容易把间距调小刷出好看曲线,实际下料时刀缝不够,整张板报废。

2.3 一套可跑的最小实现

先把解码器写成纯函数,输入排列、零件表、板材尺寸和间距,输出放置列表或None。这段代码不依赖界面,方便单独测试。

# 简化版 BL 解码:整数坐标,kerf 作为占位外扩,margin 作为板边距 from dataclasses import dataclass @dataclass class Part: pid: str w: int h: int allow_rotate: bool = True @dataclass class Placement: pid: str x: int y: int w: int h: int rotated: bool def overlap(a, b, kerf): # a/b: (x, y, w, h),用外扩 kerf 后的占位框判断 ax2, ay2 = a[0] + a[2] + kerf, a[1] + a[3] + kerf bx2, by2 = b[0] + b[2] + kerf, b[1] + b[3] + kerf return not (ax2 <= b[0] or bx2 <= a[0] or ay2 <= b[1] or by2 <= a[1]) def decode_bl(order, parts, board_w, board_h, kerf=3, margin=5): placed = [] for idx in order: p = parts[idx] orients = [(p.w, p.h, False)] if p.allow_rotate and p.w != p.h: orients.append((p.h, p.w, True)) best = None # 候选左下角:板边距 + 已放矩形的右边缘/上边缘 xs = {margin} ys = {margin} for q in placed: xs.add(q.x + q.w + kerf) ys.add(q.y + q.h + kerf) for w, h, rot in orients: for x in sorted(xs): for y in sorted(ys): if x + w + margin > board_w or y + h + margin > board_h: continue cand = (x, y, w, h) if any(overlap(cand, (q.x, q.y, q.w, q.h), kerf) for q in placed): continue key = (y, x, h, w) if best is None or key < best[0]: best = (key, Placement(p.pid, x, y, w, h, rot)) if best is None: return None placed.append(best[1]) return placed

这段代码的逻辑是:按染色体顺序逐块放置,候选位置只从边距和已放矩形的右、上边缘生成,每个方向都做边界检查和两两重叠检查,最后选(y, x)最小的位置。overlap里把kerf加在右侧和下侧,是为了让间距检查和解码口径一致;绘制时用Placement里的实际w/h,不要把kerf画进零件轮廓。

参数上,board_w和board_h要传板材内尺寸,不要把边距重复减两次;margin是板边留边,kerf是零件之间的切割间距,两者不要混。allow_rotate控制单块零件能否旋转,纹理方向有要求时直接关掉。这个解码器返回None表示当前顺序放不下所有零件,遗传算法会把它判成不可行解。多板场景在后面单独处理,先把单板跑通。

遗传算法部分不用写得太重,排列编码配 OX 交叉、交换变异、锦标赛选择和精英保留就能跑。下面是一个最小主循环,重点看数据流,不要直接拿默认参数上生产。

import random def fitness(ind, parts, bw, bh, kerf, margin): placed = decode_bl(ind, parts, bw, bh, kerf, margin) if placed is None: return -1e9, None used = sum(p.w * p.h for p in placed) area = bw * bh # 单板:放置面积占比;未放置已经在 decode 返回 None 时体现 return used / area, placed def tournament(pop, scores, k=3): cand = random.sample(list(range(len(pop))), k) return max(cand, key=lambda i: scores[i]) def ox_cross(p1, p2): n = len(p1) a, b = sorted(random.sample(range(n), 2)) child = [None] * n child[a:b] = p1[a:b] fill = [g for g in p2 if g not in child] j = 0 for i in range(n): if child[i] is None: child[i] = fill[j] j += 1 return child def mutate(ind, rate=0.1): ind = ind[:] if random.random() < rate: i, j = random.sample(range(len(ind)), 2) ind[i], ind[j] = ind[j], ind[i] return ind def run_ga(parts, bw, bh, kerf=3, margin=5, pop_size=60, gens=300, crossover_rate=0.8, mutation_rate=0.2, elite=2, seed=42): random.seed(seed) n = len(parts) pop = [random.sample(range(n), n) for _ in range(pop_size)] best_ind, best_fit, best_placed = None, -1e9, None history = [] for g in range(gens): scores, placed_list = [], [] for ind in pop: f, placed = fitness(ind, parts, bw, bh, kerf, margin) scores.append(f) placed_list.append(placed) idx = max(range(len(pop)), key=lambda i: scores[i]) if scores[idx] > best_fit: best_fit, best_ind, best_placed = scores[idx], pop[idx][:], placed_list[idx] history.append(best_fit) new_pop = [pop[i][:] for i in sorted(range(len(pop)), key=lambda i: scores[i], reverse=True)[:elite]] while len(new_pop) < pop_size: p1 = pop[tournament(pop, scores)] p2 = pop[tournament(pop, scores)] child = ox_cross(p1, p2) if random.random() < crossover_rate else p1[:] child = mutate(child, mutation_rate) new_pop.append(child) pop = new_pop return best_ind, best_fit, best_placed, history

这段主循环的逻辑是:种群由随机排列组成,每代解码所有个体,记录历史最优,然后用锦标赛选父代,OX 交叉保留一段连续基因,交换变异引入扰动,精英直接进入下一代。history只存历史最优适应度,界面画迭代曲线时用它,不要每代重画所有个体,否则数据量大了界面会卡。

参数上,pop_size从 60 到 200 都常见,零件数少时取小一点,零件数多时取大一点;gens先跑 300 代看曲线是否已经平了;crossover_rate在 0.7 到 0.9 之间;mutation_rate在 0.05 到 0.3 之间,太小容易早熟,太大退化成随机搜索;elite保留 1 到 5 个,太多会压缩多样性。seed一定要暴露到界面上,否则同一个板单两次结果不一样,排查时没有后悔药。

3. 两套可视化界面怎么分工:参数调试台与结果预览台

3.1 参数调试台:板宽、间距、旋转与种群规模

资源里带两套可视化界面,我建议把它们理解成两个岗位:参数调试台负责“试”,结果预览台负责“验”。参数调试台上不要一上来就放排样大图,先把板材宽高、边距、切割间距、是否允许旋转、种群规模、迭代代数、交叉率、变异率、精英数、随机种子这些控件摆清楚,右边配一条迭代曲线和一个当前最优利用率读数。调参时用户改的是输入,看的是曲线趋势,不需要每代刷新排样图;排样图刷新频率一高,主线程很容易被绘图拖死。

参数调试台的控件和算法参数要有一张明确的映射表,不能控件叫“间距”,算法里却用了边距。下面这张表是我拆这类界面时必对的字段,少一个都可能在后面排查时找不到原因。

界面控件算法参数常见取值调错后的典型现象
板材宽/高board_w/board_h按内尺寸填零件整体越界或右边留白异常
板边距margin3 到 10切边余量不够,第一块就贴边
切割间距kerf0 到 5图形看着有缝,导出后刀缝不够
允许旋转allow_rotate按零件定纹理方向被转错,或利用率虚高
种群规模pop_size60 到 200太小曲线抖,太大单代卡顿
迭代代数gens200 到 1000太小没收敛,太大等不到结果
交叉率crossover_rate0.7 到 0.9太低搜索慢,太高破坏优良排列
变异率mutation_rate0.05 到 0.3太低早熟,太高像随机洗牌
随机种子seed固定整数不固定则结果不可复现

这张表的作用不是让你照抄数值,而是让界面字段和解码器、适应度函数里的变量一一对应。我见过一种翻车:界面上的“间距”实际传给了margin,解码器里的kerf还是默认 0,排样图看着很满,导出坐标一算,零件之间全是零间距,切割机直接报警。参数调试台的第一版不需要花哨,能把这张表跑通,再加“运行”“暂停”“重置种子”三个按钮就够用。

迭代曲线的横轴是代数,纵轴是历史最优适应度,不要画当前种群平均适应度当主曲线,平均适应度受不可行解影响很大,看起来像心电图。曲线旁边给一个“当前最优排样”的缩略图,尺寸不用大,能看出零件大概分布就行。真正的大图留给结果预览台,这样两个界面各司其职,调参时响应快,验收时信息全。

3.2 结果预览台:排样图、迭代曲线与利用率台账

结果预览台要做三件事:画排样图、列坐标表、算利用率台账。排样图最容易出错的是坐标系。切割图习惯把板材左上角当原点,y 轴向下;很多绘图库默认 y 轴向上,直接画会把整张图上下颠倒。我一般把解码器输出的坐标统一成“左上角原点、y 向下”,绘图时要么翻转 y 轴,要么在渲染层做一次y_draw = board_h - y - h的转换,转换只做一次,不要在图和导出里各做一次,否则坐标对不上。

排样图上每块零件要标pid,颜色按零件类型分组,不要把kerf画成零件的一部分。零件之间留出的缝就是间距,绘制时用实际w/h,位置用解码器给的x/y。如果画出来的图看着有重叠,先别怀疑遗传算法,先检查绘制时有没有把kerf加到宽高上,或者旋转后的宽高有没有互换。利用率台账建议同时列板材面积、零件净面积、占位面积、边距和刀缝消耗、余料面积,四个数能对上,再谈优化。台账里的利用率口径要和参数调试台一致,不要一个按整板算,一个按内尺寸算。

坐标表不要只给x/y,要给pid、板号、x、y、w、h、rotated,旋转过的零件单独标出来。导出前用同一份结果数据同时驱动图和表,不要图从placements画,表从另一个临时列表取,两边一旦不同步,排查起来就是黑匣子。结果预览台还可以加一个“未放置零件”区域,单板放不下时把unplaced列出来,比只弹一个“排样失败”有用得多。

3.3 界面与算法解耦:用同一份结果数据驱动两个窗口

两个界面最容易写乱的地方是算法层直接操作控件:GA 跑到一半去改进度条,解码器去读输入框,最后线程一开就崩。我的做法是算法层只返回一个统一的结果字典,界面层只消费这个字典,参数调试台和结果预览台都从同一份结构取数。下面这个结构不依赖具体 GUI 框架,Tkinter、PyQt 或别的桌面框架都能接。

# 算法层只返回统一结果,界面层只消费,不反向依赖控件 result = { "board": {"w": 1220, "h": 2440, "margin": 5, "kerf": 3}, "placements": [ {"pid": "P01", "x": 5, "y": 5, "w": 300, "h": 200, "rotated": False}, ], "unplaced": [], "used_area": 60000, "board_area": 2976800, "utilization": 0.0201, "history": [0.62, 0.68, 0.71], "seed": 42, }

这个结构的逻辑是:board保存本次运行实际使用的板参数,placements是唯一坐标源,unplaced明确列出没放下的零件,used_area和board_area用来复算利用率,history给曲线,seed给复现。界面拿到这个字典后,参数调试台只读history和utilization,结果预览台只读placements和unplaced,两个窗口不互相传控件引用。

参数上,used_area只算零件净面积,不要把kerf和margin加进去;board_area用内尺寸面积还是整板面积要在字段名或注释里写清楚,我一般再加一个area_mode字段,避免后面换人接手时猜。seed必须回传,用户看到好结果想再跑一次,没有种子就只能重新试。

GA 跑在界面主线程上是另一个必踩的坑。Tkinter 不是线程安全的,子线程里直接改控件会偶发崩溃;稳妥做法是工作线程只往队列里放进度和最终结果,主线程用after轮询队列再刷新界面。PyQt 则用信号槽,把结果从工作线程发回主线程。下面这段是 Tkinter 的常见写法,重点看“子线程不碰控件”这条边界。

# Tkinter 常见做法:工作线程只放队列,主线程 after 轮询,避免界面卡死 import queue, threading q = queue.Queue() def worker(): # 调用 run_ga,把进度和最终 result 放进队列 q.put(("progress", 0.5)) q.put(("done", result)) def poll(): try: while True: kind, payload = q.get_nowait() if kind == "progress": progress_var.set(payload) elif kind == "done": render_result(payload) except queue.Empty: pass root.after(100, poll)

这段代码的逻辑是:worker在后台跑算法,只把消息塞进queue.Queue;主线程的poll每 100 毫秒取一次消息,取到进度就改进度条,取到结果就调渲染函数。render_result只在主线程执行,绘图和控件更新都安全。

参数上,轮询间隔 100 毫秒对排样界面够用,太快会空转耗 CPU,太慢进度条像卡住。队列里不要塞大对象的高频副本,placements只在done消息里传一次,进度消息只传百分比或当前代数。如果 GA 单代耗时很长,可以在解码循环里按代数发进度,但不要每放一块零件就发一条,队列会爆。

4. 矩形切割场景的参数与约束:间距、旋转、数量和多板

4.1 切割间距和边距:别把理论最优当车间可用

矩形切割里最容易混的两个参数是kerf和margin。kerf是零件与零件之间的切割间距,来源可能是刀缝、封边余量或热缩余量;margin是板材四周的留边,来源是夹持、修边或崩边风险。解码器里kerf参与矩形之间的占位判断,margin参与板材边界判断,两者作用位置不同。有人为了图省事,把kerf直接加到零件宽高上再排,结果零件面积被放大,利用率算出来偏低;也有人把margin只加在左、上边,右、下边忘了加,最后一块零件贴边下料。

下面这份配置是我拆排样项目时常用的最小字段,零件数量、旋转和纹理方向都在里面,界面读它,算法也读它,避免两套参数。

{ "board": {"w": 1220, "h": 2440, "margin": 8}, "kerf": 3, "parts": [ {"pid": "A", "w": 300, "h": 200, "qty": 4, "allow_rotate": true, "grain": "none"}, {"pid": "B", "w": 600, "h": 400, "qty": 2, "allow_rotate": false, "grain": "length"} ] }

这份配置的逻辑是:板材内尺寸 1220×2440,四周留边 8,零件之间间距 3;A 零件可旋转,B 零件因纹理方向不允许旋转;qty表示数量,解码前要把同规格零件展开成多条记录,但展示和导出时按pid聚合。参数上,margin不要和kerf合并成一个“间隙”字段,后面调刀缝时会把边距一起改掉;grain只作为业务约束,不参与几何计算,但决定allow_rotate能不能开。

注意:kerf和margin的口径必须和解码器、校验器、绘图三处一致,任何一处多算或少算,利用率和坐标都会对不上。

4.2 旋转与纹理方向:允许旋转不等于随便转

矩形排样允许 90 度旋转,能明显提高利用率,但不是所有零件都能转。木材、石材、带纹路的板材有纹理方向,旋转后纹路横竖不一致,外观件直接报废;有些零件有孔位、开槽或封边方向,旋转后加工基准变了。我的做法是给每个零件一个allow_rotate字段,默认关闭,只有确认无纹理、无方向要求的零件才打开。遗传算法解码时对允许旋转的零件尝试两个方向,对不允许旋转的只试原始方向,不要在图省事在解码器里全局开旋转。

旋转还有一个几何坑:宽高互换后,间距加在哪一边。如果放置时统一用w + kerf和h + kerf做占位,旋转后w/h已经互换,间距方向自然跟着换,不用额外判断。但如果代码里写死了“右边加 kerf、下边加 kerf”,旋转后仍然按新的w/h算,逻辑是对的;怕的是绘图时用原始w/h画,占位时用旋转后的w/h算,图和校验器就会打架。统一以Placement里的w/h/rotated为准,原始零件尺寸只用来展示。

纹理方向还会影响多板排料的顺序。同一张板上混排不同纹理方向的零件,视觉上可能很乱,车间也不愿意接。我一般把纹理方向作为分组条件,先按方向分组,再在组内做排样,跨组共板只在余料足够大时考虑。这个约束不一定要写进遗传算法,可以在解码前把零件表拆成子任务,界面上的“允许混排”开关控制是否合并。这样算法层不用懂业务,业务约束在数据准备阶段解决。

4.3 数量约束与多板材:从单板排样到批量排料

单板排样跑通后,下一步是多板。多板解码的常见做法是顺序填充:按染色体顺序取零件,先尝试放当前板,放不下就封板、开新板,再放一次;如果新板也放不下,说明单件尺寸超过板材,整单不可行。适应度从“单板利用率”换成“总零件面积除以使用板数乘板材面积”,再对板数加一点惩罚。这样遗传算法会偏向用更少板、且最后一张板不太空的排列。

def decode_multi(order, parts, bw, bh, kerf, margin): boards = [] current = [] for idx in order: p = parts[idx] # 先尝试当前板,放不下再开新板;这里省略 BL 细节 placed = try_place(current, p, bw, bh, kerf, margin) if placed is None: boards.append(current) current = [] placed = try_place(current, p, bw, bh, kerf, margin) if placed is None: return None # 单件都放不下,整单不可行 current.append(placed) boards.append(current) return boards

这段伪代码的逻辑是:try_place在当前板已放矩形基础上尝试放一块零件,成功返回放置对象,失败返回None;当前板放不下就封板开新板,新板还放不下就判整单不可行。参数上,bw/bh是单板内尺寸,kerf/margin和单板解码保持一致;boards的长度就是使用板数,适应度里用它做分母。

多板场景还要处理数量约束。同规格零件有 4 件,染色体里会出现 4 个相同pid的索引,解码时按索引取零件,导出时再按pid聚合数量。不要为了去重把相同零件合并成一个基因,否则数量约束丢了。界面上的坐标表可以按板号分组,每块零件一行,同pid多行是正常的。余料统计也一样,封板后对每张板做一次空闲矩形扫描,找出最大可回收余料块,列在台账里,别只给一个总利用率。

多板排料的参数扫描比单板更依赖业务目标。只追求利用率,算法可能把零件摊到很多张板上,每张都不空;只追求板数,最后一张板可能碎得没法下料。我一般同时看三个数:使用板数、最后一张板利用率、最大余料块尺寸。三个数都过得去,再保存这组参数和种子。

5. 避坑与排查:排样图好看却下不了料的四个现场问题

5.1 利用率虚高:重叠与越界没被校验

现象:界面显示利用率 92%,排样图看着很满,导出坐标后切割机报警,有的零件压线,有的零件超出板边。把图放大看,零件之间似乎有缝,但坐标表里两块零件的间距小于kerf,甚至重叠了几个像素。

原因:解码器和绘图用了两套口径。绘图时按实际w/h画,留白看起来正常;解码时重叠检测只比较了左上角,或者用了浮点坐标,0.1 + 0.2这类比较在边界上失效。旋转后的零件宽高互换,但重叠检测还在用原始宽高。边距只加在左、上,右、下边界检查漏了margin。

解决:写一个独立校验器,不信任解码器的返回值,对最终placements重新做边界检查和两两重叠检查。坐标统一用整数,间距检查用a.x + a.w + kerf <= b.x这种闭区间写法,和放置时外扩口径一致。下面这段校验器可以直接接到结果预览台的“校验”按钮上。

def validate(placements, bw, bh, margin, kerf): errors = [] for p in placements: if p.x < margin or p.y < margin: errors.append((p.pid, "left/top margin")) if p.x + p.w + margin > bw or p.y + p.h + margin > bh: errors.append((p.pid, "out of board")) for i in range(len(placements)): for j in range(i + 1, len(placements)): a, b = placements[i], placements[j] if not (a.x + a.w + kerf <= b.x or b.x + b.w + kerf <= a.x or a.y + a.h + kerf <= b.y or b.y + b.h + kerf <= a.y): errors.append((a.pid, b.pid, "overlap/kerf")) return errors

这段代码的逻辑是:先逐块检查左上边距和右下边界,再两两检查矩形在 x 或 y 方向上是否有足够间隔。a.x + a.w + kerf <= b.x表示 a 的右边缘加间距不超过 b 的左边缘,四个条件满足一个就不重叠。

参数上,bw/bh必须和解码器传入的内尺寸一致,margin和kerf也要同口径。校验器返回空列表才算通过;返回overlap/kerf时

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询