Files
DP/pltreader.py

602 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'''
参考资料:
https://www.gnu.org/software/hp2xx/hp2xx.html
https://zhuanlan.zhihu.com/p/622090369
'''
import os
import typing
import string
import re
import warnings
from enum import Enum
import shapely
import shapely.ops
from shapely.geometry import LineString, Polygon, MultiPolygon, MultiLineString
import numpy as np
import cv2
import itertools
from shapely.validation import make_valid
def my_warning_handler(message, category, filename, lineno, file=None, line=None):
print(f"PLT警告: {message}")
warnings.showwarning = my_warning_handler
def assert_warning(condition, message):
if not condition:
warnings.warn(message, UserWarning)
class PltCommand(Enum):
IN = 0
PU = 1
PD = 2
PA = 3
AbstractSyntaxTree = typing.List[typing.Dict]
class VirtualMachineOutput(object):
'''虚拟机执行后的结果'''
MIN_FILTER_AREA_FACTOR = 0.0068 ** 2
MAX_FILTER_AREA_FACTOR = 0.8 ** 2
def __init__(self, polygons :typing.List[Polygon], dispensable: typing.List[LineString]) -> None:
for poly in polygons:
assert isinstance(poly, Polygon)
for line in dispensable:
assert isinstance(line, LineString)
# 过滤结果
# 计算画布大小
min_x, min_y, max_x, max_y = MultiPolygon(polygons).bounds
filter_polygons = []
for polygon in polygons:
if (max_x - min_x) * (max_y - min_y) * VirtualMachineOutput.MIN_FILTER_AREA_FACTOR < polygon.area < (max_x - min_x) * (max_y - min_y) * VirtualMachineOutput.MAX_FILTER_AREA_FACTOR:
filter_polygons.append(polygon)
else:
boundary = polygon.boundary
if isinstance(boundary, LineString):
dispensable.append(boundary)
elif isinstance(boundary, MultiLineString):
dispensable.extend(list(boundary.geoms))
print(f"过滤了{len(polygons) - len(filter_polygons)}个多边形")
polygons = filter_polygons
# 计算多边形的包含关系,构建多边形树
self.nodes = [{"data":polygon, "child":[], "parent":None, "index":index} for index, polygon in enumerate(polygons)]
self.dispensable = dispensable
# 找每个node的最佳parent
for node1 in self.nodes:
best_parent = None
for node2 in self.nodes:
if node1 == node2:
continue
if node1["index"] == 8 and node2["index"] == 9:
pass
# 判断是否包含
#if node2["data"].contains(node1["data"]):
if node2["data"].area > node1["data"].area:
intersection = node2["data"].intersection(node1["data"])
if intersection.area > node1["data"].area * 0.99:
if best_parent is None:
best_parent = node2
else:
if best_parent["data"].contains(node2["data"]):
best_parent = node2
node1["parent"] = best_parent
# 填充child
self.tree = []
for node in self.nodes:
if node["parent"] is not None:
node["parent"]["child"].append(node)
else:
self.tree.append(node)
# 构建广度优先遍历
self.bfs = self.tree.copy()
index = 0
while index < len(self.bfs):
self.bfs.extend(self.bfs[index]["child"])
index += 1
def _draw_nodes(self, nodes: typing.List, scale_factor: float, show_id: bool = True) -> np.ndarray:
# 计算画布大小
min_x, min_y, max_x, max_y = MultiPolygon([node["data"] for node in nodes]).bounds
max_x = int(np.ceil(max_x * scale_factor))
max_y = int(np.ceil(max_y * scale_factor))
min_x = int(np.floor(min_x * scale_factor))
min_y = int(np.floor(min_y * scale_factor))
max_x -= min_x
max_y -= min_y
#print("画布大小", max_x, max_y)
result_img = np.zeros((max_y, max_x, 4), dtype=np.uint8)
nodes = sorted(nodes, key=lambda node: self.bfs.index(node))
for node in nodes:
pts = (np.asarray(node["data"].exterior.coords) * scale_factor - [min_x, min_y]).astype(np.int32)
pts[:,1] = max_y - pts[:,1]
cv2.fillPoly(result_img, [pts], (255,255,255,255))
# 最外圈parent为None用红色内部用黑色
if node["parent"] is None:
cv2.polylines(result_img, [pts], True, (0,0,255,255), 2) # 红色 (BGR: 0,0,255)
else:
cv2.polylines(result_img, [pts], True, (0,0,0,255), 2) # 黑色
# 画无关紧要的东西dispensable线段
multiPolygon = MultiPolygon([node["data"] for node in nodes])
multiPolygon = make_valid(multiPolygon.buffer(0))
# 获取最外圈轮廓parent为None的节点
outer_contours = [node["data"].exterior for node in nodes if node["parent"] is None]
for shape in self.dispensable:
if multiPolygon.contains(shape):
pts = (np.asarray(shape.coords) * scale_factor - [min_x, min_y]).astype(np.int32)
pts[:,1] = max_y - pts[:,1]
# 判断是否与最外圈轮廓粘连(距离非常近)
is_connected_to_outer = False
line_length = shape.length # 线段长度PLT单位
# 距离阈值PLT单位小于此值视为粘连
CONNECT_THRESHOLD = 1
# 线段长度限制PLT单位
MAX_LINE_LENGTH = 155
if line_length < MAX_LINE_LENGTH: # 只检查短线段
for outer in outer_contours:
distance = shape.distance(outer)
if distance < CONNECT_THRESHOLD:
is_connected_to_outer = True
break
if is_connected_to_outer:
# ========== 调试开关 ==========
# True = 蓝色标记模式(用于调试查看哪些线被检测到)
# False = 镂空模式正式使用挖空0.2cm宽度)
DEBUG_MODE = False
if DEBUG_MODE:
# 调试模式:蓝色标记,方便查看检测结果
cv2.polylines(result_img, [pts], False, (255,0,0,255), 2) # 蓝色加粗
else:
# 正式模式:镂空效果(方形端点)
# 用矩形多边形代替polylines避免圆形端点
cutout_half_width = int(15 * scale_factor) # 半宽度
if cutout_half_width < 1:
cutout_half_width = 1
# 对每个线段创建矩形多边形
for i in range(len(pts) - 1):
p1 = pts[i]
p2 = pts[i + 1]
# 计算线段方向的垂直向量
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
length = np.sqrt(dx*dx + dy*dy)
if length < 0.001:
continue
# 垂直方向单位向量
nx = -dy / length * cutout_half_width
ny = dx / length * cutout_half_width
# 构建矩形的四个角点
rect_pts = np.array([
[p1[0] + nx, p1[1] + ny],
[p1[0] - nx, p1[1] - ny],
[p2[0] - nx, p2[1] - ny],
[p2[0] + nx, p2[1] + ny]
], dtype=np.int32)
# 用透明色填充矩形
cv2.fillPoly(result_img, [rect_pts], (0,0,0,0))
else:
cv2.polylines(result_img, [pts], False, (0,0,0,255), 1) # 黑色
# 画parent为None的id
if show_id:
for node in nodes:
if node["parent"] is None:
centroid = node["data"].centroid
text_pos = (int((centroid.x * scale_factor) - min_x), int(max_y - (centroid.y * scale_factor) - min_y))
cv2.putText(result_img, str(node["index"]), text_pos, cv2.FONT_HERSHEY_SIMPLEX, 3, (255,0,0,255), 5)
return result_img
def full_sheet(self, scale_factor: float = 0.1) -> np.ndarray:
'''整幅输出'''
return self._draw_nodes(self.bfs, scale_factor)
def debug_full_sheet(self, scale_factor: float = 0.1) -> np.ndarray:
"""
用随机颜色画出所有多边形和被过滤掉的线段
"""
# 计算画布大小画布大小应该考虑dispensable
all_shapes = [node["data"] for node in self.nodes] + self.dispensable
temp = []
for shape in all_shapes:
if isinstance(shape, LineString):
convex_hull = shape.convex_hull
if isinstance(convex_hull, Polygon):
temp.append(convex_hull)
else:
temp.append(shape)
min_x, min_y, max_x, max_y = MultiPolygon(temp).bounds
max_x = int(np.ceil(max_x * scale_factor))
max_y = int(np.ceil(max_y * scale_factor))
min_x = int(np.floor(min_x * scale_factor))
min_y = int(np.floor(min_y * scale_factor))
max_x -= min_x
max_y -= min_y
result_img = np.zeros((max_y, max_x, 4), dtype=np.uint8)
# 画多边形
for node in self.nodes:
#overlay = result_img.copy()
color = tuple(np.random.randint(0,256,size=3).tolist() + [255])
pts = (np.asarray(node["data"].exterior.coords) * scale_factor - [min_x, min_y]).astype(np.int32)
pts[:,1] = max_y - pts[:,1]
cv2.fillPoly(result_img, [pts], color)
if node["parent"] is None:
cv2.polylines(result_img, [pts], True, (0,0,255,255), 1)
else:
cv2.polylines(result_img, [pts], True, (0,0,0,255), 1)
#alpha = 0.5
#cv2.addWeighted(overlay, alpha, result_img, 1 - alpha, 0, result_img)
# 画被过滤掉的线段
for shape in self.dispensable:
#overlay = result_img.copy()
color = tuple(np.random.randint(0,256,size=3).tolist() + [255])
pts = (np.asarray(shape.coords) * scale_factor - [min_x, min_y]).astype(np.int32)
pts[:,1] = max_y - pts[:,1]
cv2.polylines(result_img, [pts], False, color, 1)
#cv2.addWeighted(overlay, alpha, result_img, 1 - alpha, 0, result_img)
# 画parent为None的id
for node in self.nodes:
if node["parent"] is None:
centroid = node["data"].centroid
text_pos = (int((centroid.x * scale_factor) - min_x), int(max_y - (centroid.y * scale_factor) - min_y))
cv2.putText(result_img, str(node["index"]), text_pos, cv2.FONT_HERSHEY_SIMPLEX, 3, (0,0,255,255), 5)
return result_img
def _distance_between_cluster(self, cluster1, cluster2):
min_distance = float("inf")
for node1 in cluster1:
for node2 in cluster2:
assert isinstance(node1["data"], Polygon)
assert isinstance(node2["data"], Polygon)
d = node1["data"].distance(node2["data"])
if d < min_distance:
min_distance = d
return min_distance
def get_single_size_info(self, size_num: int) -> typing.List[typing.List]:
'''获取单码信息'''
# 距离聚类
clusters = [[node] for node in self.nodes]
while len(clusters) > size_num:
# 每个循环将合并距离最小的两个类
min_distance_clusters = (None, None)
min_distance = float("inf")
for cluster1, cluster2 in itertools.combinations(clusters, 2):
d = self._distance_between_cluster(cluster1, cluster2)
if d < min_distance:
min_distance = d
min_distance_clusters = (cluster1, cluster2)
clusters.remove(min_distance_clusters[0])
clusters.remove(min_distance_clusters[1])
clusters.append(min_distance_clusters[0] + min_distance_clusters[1])
# 排序
sorted_clusters = sorted(clusters, key=lambda x: min([node["index"] for node in x]))
return list(sorted_clusters)
def single_size(self, size_num: int, scale_factor: float = 0.1) -> typing.List[np.ndarray]:
'''单码输出'''
sorted_clusters = self.get_single_size_info(size_num)
result = []
for cluster in sorted_clusters:
result.append(self._draw_nodes(cluster, scale_factor))
return result
def single_piece(self, scale_factor: float = 0.1, show_id: bool = False) -> typing.List[typing.Tuple[int, np.ndarray]]:
'''单片输出
返回: [(裁片ID, 图像), ...]
'''
result = []
for node in self.nodes:
# 如果是顶层则新建一副图像
if node["parent"] is None:
nodes = [node] + node['child']
img = self._draw_nodes(nodes, scale_factor, show_id=show_id)
result.append((node["index"], img))
return result
class _VirtualMachine(object):
'''运行PLT文件的虚拟机'''
def __init__(self, tolerance) -> None:
self.tolerance = tolerance
def run(self, abstract_syntax_tree: AbstractSyntaxTree) -> VirtualMachineOutput:
context = abstract_syntax_tree.copy()
current_x = 0
current_y = 0
self.pen_state = 'UP'
lines :typing.List[typing.List[typing.Tuple]] = []
lines_flag :typing.List[int] = []
while len(context) != 0:
code = context[0]
try:
if code["cmd"] == PltCommand.IN:
print("初始化")
elif code["cmd"] == PltCommand.PA:
if self.pen_state == 'UP':
pass
elif self.pen_state == 'DOWN':
new_x = float(code["args"][0])
new_y = float(code["args"][1])
# 判断是否与已有的线相连接
for i in range(len(lines) - 1, -1, -1):
if lines_flag[i] == 1:
continue
if lines[i][0] == (current_x, current_y):
lines[i].insert(0, (new_x, new_y))
if lines[i][0] == lines[i][-1]:
lines_flag[i] = 1
break
elif lines[i][0] == (new_x, new_y):
lines[i].insert(0, (current_x, current_y))
if lines[i][0] == lines[i][-1]:
lines_flag[i] = 1
break
elif lines[i][-1] == (current_x, current_y):
lines[i].append((new_x, new_y))
if lines[i][0] == lines[i][-1]:
lines_flag[i] = 1
break
elif lines[i][-1] == (new_x, new_y):
lines[i].append((current_x, current_y))
if lines[i][0] == lines[i][-1]:
lines_flag[i] = 1
break
else:
# 不与已有的线相连接
lines.append([(current_x, current_y), (new_x, new_y)])
lines_flag.append(0)
current_x = float(code["args"][0])
current_y = float(code["args"][1])
elif code["cmd"] == PltCommand.PU:
self.pen_state = 'UP'
elif code["cmd"] == PltCommand.PD:
self.pen_state = 'DOWN'
except Exception as e:
assert_warning(False, f"[code {code}] 执行指令异常: {e}")
finally:
context = context[1:]
# 创建LineString :typing.List[LineString] = []
lineStrings :typing.List[LineString] = []
lineStrings = [LineString(line) for line in lines if len(set(line)) > 1]
'''
import matplotlib.pyplot as plt
for line in lineStrings:
x, y = line.xy
plt.plot(x, y)
plt.show()
'''
return self.__build_polygons_and_dispensable(lineStrings)
def __build_polygons_and_dispensable(self, lineStrings: typing.List[LineString]) -> VirtualMachineOutput:
# 为非常近的线搭桥
tree = shapely.STRtree(lineStrings)
bridges = []
# 遍历所有线段的端点,寻找需要搭桥的地方
for line in lineStrings:
endpoints = [shapely.Point(line.coords[0]), shapely.Point(line.coords[-1])]
for p in endpoints:
for idx in tree.query(p.buffer(self.tolerance)):
other_line = lineStrings[idx]
if other_line == line:
continue
dist = p.distance(other_line)
if 0 < dist <= self.tolerance:
_, p_target = shapely.ops.nearest_points(p, other_line)
bridge = LineString([p, p_target])
bridges.append(bridge)
lineStrings.extend(bridges)
# 节点化并提取闭合区域
all_shapes = shapely.ops.unary_union(lineStrings)
assert isinstance(all_shapes, MultiLineString)
polygons :typing.List[Polygon] = list(shapely.ops.polygonize(all_shapes))
# 合并重叠多边形
merged_polygons = shapely.unary_union(polygons)
assert isinstance(merged_polygons, MultiPolygon)
polygons = list(merged_polygons.geoms)
# 只保留外环
polygons = [Polygon(poly.exterior) for poly in polygons]
# 找到多余的线段
dispensable :typing.List[LineString] = []
closed_rings = [poly.exterior for poly in polygons]
if closed_rings:
leftovers = all_shapes.difference(shapely.ops.unary_union(closed_rings))
assert isinstance(leftovers, shapely.MultiLineString)
for shape in leftovers.geoms:
dispensable.append(shape)
else:
dispensable = list(all_shapes.geoms)
for i in range(len(polygons)):
polygons[i] = polygons[i].buffer(0)
print(f"共生成多边形{len(polygons)}个,线段{len(dispensable)}")
output = VirtualMachineOutput(polygons, dispensable)
return output
class _Preprocessor(object):
'''PLT文件预处理器'''
def __init__(self) -> None:
pass
def get_abstract_syntax_tree(self, src_content: str) -> AbstractSyntaxTree:
content = src_content
# 换行符转分号 某些文件的语句末尾没有分号
content = content.replace("\n", ";")
content = content.replace(" ", ",")
# 删除所有空白字符
for space in string.whitespace:
content = content.replace(space, "")
result = []
# 遍历每一行命令
for line_index, line in enumerate(content.split(";")):
if line == "":
continue
# 提取开头的英文
pattern = r'^[a-zA-Z]+'
match = re.search(pattern, line)
if match is None:
assert_warning(False, f"[line {line_index}] 未找到命令: {line}")
continue
# 获取对应的指令
command_str = match.group().upper()
try:
command = PltCommand[command_str]
except KeyError:
assert_warning(False, f"[line {line_index}] 不支持的指令: {command_str}")
continue
# 获取参数
if len(command_str) == len(line):
stack = []
else:
stack = line[len(command_str):].split(",")
if command == PltCommand.IN:
result.append({'cmd': PltCommand.IN, 'args': []})
elif command == PltCommand.PA:
for i in range(len(stack) // 2):
result.append({'cmd': PltCommand.PA, 'args': [stack[0], stack[1]]})
stack = stack[2:]
elif command == PltCommand.PU:
result.append({'cmd': PltCommand.PU, 'args': []})
for i in range(len(stack) // 2):
result.append({'cmd': PltCommand.PA, 'args': [stack[0], stack[1]]})
stack = stack[2:]
elif command == PltCommand.PD:
result.append({'cmd': PltCommand.PD, 'args': []})
for i in range(len(stack) // 2):
result.append({'cmd': PltCommand.PA, 'args': [stack[0], stack[1]]})
stack = stack[2:]
assert_warning(len(stack) == 0, f"[line {line_index}] 栈非空: {stack}")
return result
class PltReader(object):
'''PLT文件读取器'''
def __init__(self, pltfile: typing.TextIO) -> None:
self.pltfile = pltfile
preprocessor = _Preprocessor()
self.abstract_syntax_tree = preprocessor.get_abstract_syntax_tree(self.pltfile.read())
def get_output(self, tolerance=5):
machine = _VirtualMachine(tolerance=tolerance)
output = machine.run(self.abstract_syntax_tree)
return output
if __name__ == "__main__":
filepath = "默认方案-POLO衫长袖-A料-标准.plt"
with open(filepath, 'r') as f:
reader = PltReader(f)
output = reader.get_output()
# 调试整幅输出(随机颜色)
debug_full_sheet_path = "debug_full_sheet.png"
cv2.imwrite(debug_full_sheet_path, output.debug_full_sheet(scale_factor=72/1016))
print(f"调试整幅输出已写入: {debug_full_sheet_path}")
# 整幅输出
full_sheet_path = "full_sheet.png"
cv2.imwrite(full_sheet_path, output.full_sheet(scale_factor=72/1016))
print(f"整幅输出已写入: {full_sheet_path}")
# 单码输出
single_size_path = "single_size"
os.makedirs(single_size_path, exist_ok=True)
for index, img in enumerate(output.single_size(size_num=5, scale_factor=72/1016)):
cv2.imwrite(os.path.join(single_size_path, f"size_{index}.png"), img)
print(f"单码输出已写入: {single_size_path}/ (共{len(os.listdir(single_size_path))}个文件)")
# 单片输出 - 每个裁片独立保存为PNG
single_piece_path = "single_piece"
os.makedirs(single_piece_path, exist_ok=True)
pieces = output.single_piece(scale_factor=72/1016, show_id=False)
for piece_id, img in pieces:
cv2.imwrite(os.path.join(single_piece_path, f"piece_{piece_id}.png"), img)
print(f"单片输出已写入: {single_piece_path}/ (共{len(pieces)}个裁片)")