feat: AI套图分层方案 + Gemini集成 - 4种图案类型处理 + 正片叠底 + 宽高比 + 模型选择

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-07 16:59:56 +08:00
parent 12395d8eca
commit dae906aba7
277 changed files with 15009 additions and 19922 deletions

32
PltService/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# PLT 处理微服务 Dockerfile
# 用于部署到阿里云 SAE
FROM python:3.10-slim
# 设置工作目录
WORKDIR /app
# 安装系统依赖OpenCV 需要)
RUN apt-get update && apt-get install -y \
libgl1-mesa-glx \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
# 复制代码
COPY pltreader.py .
COPY main.py .
# 暴露端口SAE 默认使用 8080
EXPOSE 8080
# 启动命令
CMD ["python", "main.py"]

112
PltService/README.md Normal file
View File

@@ -0,0 +1,112 @@
# PLT 裁片处理微服务
独立的 PLT 文件处理服务,可部署到阿里云 SAE。
## 本地运行
```bash
# 安装依赖
pip install -r requirements.txt
# 启动服务
python main.py
```
服务启动后访问http://localhost:8080
## Docker 构建
```bash
# 构建镜像
docker build -t plt-service:latest .
# 运行容器
docker run -p 8080:8080 plt-service:latest
```
## 部署到阿里云 SAE
### 1. 构建并推送镜像到阿里云容器镜像服务
```bash
# 登录阿里云容器镜像服务
docker login --username=<你的阿里云账号> registry.cn-hangzhou.aliyuncs.com
# 构建镜像
docker build -t registry.cn-hangzhou.aliyuncs.com/<命名空间>/plt-service:v1.0 .
# 推送镜像
docker push registry.cn-hangzhou.aliyuncs.com/<命名空间>/plt-service:v1.0
```
### 2. 在 SAE 创建应用
1. 进入阿里云 SAE 控制台
2. 创建应用 → 选择"镜像部署"
3. 填写镜像地址:`registry.cn-hangzhou.aliyuncs.com/<命名空间>/plt-service:v1.0`
4. 配置规格:
- CPU: 2核
- 内存: 4GB
- 最小实例数: 0无请求时不收费
- 最大实例数: 5
5. 完成创建
### 3. 配置公网访问
在 SAE 应用详情 → 基本信息 → SLB 设置 → 添加公网 SLB
## API 接口
### 健康检查
```
GET /health
```
### 处理 PLT 文件
```
POST /process
Content-Type: multipart/form-data
参数:
- file: PLT 文件
- size_labels: 尺码标签,如 ["S","M","L","XL","2XL"]
- dpi: 输出分辨率(默认 150
- rotation: 旋转角度0/90/-90/180
```
响应示例:
```json
{
"success": true,
"total_groups": 5,
"groups": [
{
"group_id": 1,
"pieces": [
{
"size": "S",
"image_base64": "data:image/png;base64,...",
"width_px": 500,
"height_px": 300,
"width_cm": 25.5,
"height_cm": 15.3,
"center_x_cm": 12.75,
"center_y_cm": 7.65,
"left_cm": 0,
"top_cm": 0
}
]
}
]
}
```
## 费用估算(阿里云 SAE
| 场景 | 费用 |
|------|------|
| 处理 1 个 PLT30秒 | ¥0.04 |
| 每天 100 个 PLT | ¥4/天 |
| 无请求时 | ¥0最小实例设为0 |

261
PltService/main.py Normal file
View File

@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
"""
PLT 裁片处理微服务
独立部署到阿里云 SAE
"""
import os
import io
import base64
import json
from typing import List, Optional
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import numpy as np
import cv2
from PIL import Image
from shapely import affinity
from scipy.optimize import linear_sum_assignment
from pltreader import PltReader
app = FastAPI(title="PLT Processing Service")
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ==================== 数据模型 ====================
class PieceInfo(BaseModel):
"""单个裁片信息"""
size: str
image_base64: str
width_px: int
height_px: int
width_cm: float
height_cm: float
center_x_cm: float
center_y_cm: float
left_cm: float
top_cm: float
class GroupInfo(BaseModel):
"""裁片分组信息"""
group_id: int
pieces: List[PieceInfo]
class ProcessPltResponse(BaseModel):
"""API 响应"""
success: bool
total_groups: int
groups: List[GroupInfo]
# ==================== 辅助函数 ====================
def parse_plt_file(file_content: str, tolerance: int = 10):
"""解析 PLT 文件内容"""
reader = PltReader(io.StringIO(file_content))
output = reader.get_output(tolerance=tolerance)
return output
def apply_rotation_to_image(pil_img: Image.Image, rotation: int) -> Image.Image:
"""应用旋转变换"""
if rotation == 90:
return pil_img.rotate(-90, expand=True)
elif rotation == -90:
return pil_img.rotate(90, expand=True)
elif rotation == 180:
return pil_img.rotate(180, expand=True)
return pil_img
def calculate_piece_coordinates(polygon, bounds, plt_to_cm, rotation=0):
"""计算裁片坐标(厘米单位)"""
min_x, min_y, max_x, max_y = bounds
centroid = polygon.centroid
orig_center_x = (centroid.x - min_x) * plt_to_cm
orig_center_y = (centroid.y - min_y) * plt_to_cm
piece_bounds = polygon.bounds
orig_piece_width = (piece_bounds[2] - piece_bounds[0]) * plt_to_cm
orig_piece_height = (piece_bounds[3] - piece_bounds[1]) * plt_to_cm
orig_canvas_width = (max_x - min_x) * plt_to_cm
orig_canvas_height = (max_y - min_y) * plt_to_cm
if rotation == 90:
center_x = orig_center_y
center_y = orig_canvas_width - orig_center_x
piece_width, piece_height = orig_piece_height, orig_piece_width
elif rotation == -90:
center_x = orig_canvas_height - orig_center_y
center_y = orig_center_x
piece_width, piece_height = orig_piece_height, orig_piece_width
elif rotation == 180:
center_x = orig_canvas_width - orig_center_x
center_y = orig_canvas_height - orig_center_y
piece_width, piece_height = orig_piece_width, orig_piece_height
else:
center_x, center_y = orig_center_x, orig_center_y
piece_width, piece_height = orig_piece_width, orig_piece_height
left_cm = center_x - piece_width / 2
top_cm = center_y - piece_height / 2
return {
"center_x_cm": round(center_x, 2),
"center_y_cm": round(center_y, 2),
"left_cm": round(left_cm, 2),
"top_cm": round(top_cm, 2),
"width_cm": round(piece_width, 2),
"height_cm": round(piece_height, 2)
}
# ==================== API 接口 ====================
@app.get("/health")
async def health_check():
"""健康检查"""
return {"status": "healthy", "service": "plt-processor"}
@app.post("/process", response_model=ProcessPltResponse)
async def process_plt(
file: UploadFile = File(..., description="PLT 文件"),
size_labels: str = Form(..., description="尺码标签 JSON 数组"),
dpi: int = Form(150, description="输出图片分辨率DPI"),
rotation: int = Form(0, description="强制旋转角度 (0/90/-90/180)")
):
"""
PLT 裁片处理接口
"""
try:
# 1. 解析参数
try:
size_labels_list = json.loads(size_labels)
except:
raise HTTPException(status_code=400, detail="size_labels 格式错误")
size_num = len(size_labels_list)
scale_factor = dpi / 1016
plt_to_cm = 2.54 / 1016
# 2. 读取文件
file_content = (await file.read()).decode('utf-8', errors='ignore')
# 3. 解析 PLT
print(f"[PLT] 正在处理文件: {file.filename}")
output = parse_plt_file(file_content)
# 4. 获取尺码聚类
clusters = output.get_single_size_info(size_num=size_num)
# 5. 建立匹配关系
base_cluster = [piece for piece in clusters[0] if piece["parent"] is None]
match_result = {piece["index"]: [piece["index"]] for piece in base_cluster}
for size_index in range(1, len(clusters)):
compare_cluster = [piece for piece in clusters[size_index] if piece["parent"] is None]
cost_matrix = np.zeros((len(base_cluster), len(compare_cluster)))
for i in range(len(base_cluster)):
for j in range(len(compare_cluster)):
poly1 = base_cluster[i]["data"]
poly2 = compare_cluster[j]["data"]
poly1 = affinity.translate(poly1, xoff=-poly1.centroid.x, yoff=-poly1.centroid.y)
poly2 = affinity.translate(poly2, xoff=-poly2.centroid.x, yoff=-poly2.centroid.y)
target_area = 10000
scale1 = (target_area / poly1.area) ** 0.5
scale2 = (target_area / poly2.area) ** 0.5
poly1 = affinity.scale(poly1, xfact=scale1, yfact=scale1, origin=(0, 0))
poly2 = affinity.scale(poly2, xfact=scale2, yfact=scale2, origin=(0, 0))
dist_min = float('inf')
for angle in [0, 90, 180, 270]:
rotated = affinity.rotate(poly2, angle, origin='centroid')
dist = poly1.hausdorff_distance(rotated)
if dist < dist_min:
dist_min = dist
cost_matrix[i, j] = dist_min
row_ind, col_ind = linear_sum_assignment(cost_matrix)
for i, j in zip(row_ind, col_ind):
base_index = base_cluster[i]['index']
matched_index = compare_cluster[j]['index']
match_result[base_index].append(matched_index)
# 6. 生成图片
all_nodes = {node["index"]: node for node in output.nodes}
from shapely.geometry import MultiPolygon as ShapelyMultiPolygon
all_polygons = [node["data"] for node in output.nodes]
global_bounds = ShapelyMultiPolygon(all_polygons).bounds
groups = []
for group_id, (base_index, matched_indices) in enumerate(match_result.items(), start=1):
pieces = []
for size_idx, piece_index in enumerate(matched_indices):
size_label = size_labels_list[size_idx]
node = all_nodes[piece_index]
nodes_to_draw = [node] + node['child']
img = output._draw_nodes(nodes_to_draw, scale_factor, show_id=False)
img_rgba = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
pil_img = Image.fromarray(img_rgba)
pil_img = apply_rotation_to_image(pil_img, rotation)
buffer = io.BytesIO()
pil_img.save(buffer, format='PNG', dpi=(dpi, dpi))
buffer.seek(0)
base64_str = base64.b64encode(buffer.read()).decode('utf-8')
image_base64 = f"data:image/png;base64,{base64_str}"
coords = calculate_piece_coordinates(
node["data"],
global_bounds,
plt_to_cm,
rotation
)
piece_info = PieceInfo(
size=size_label,
image_base64=image_base64,
width_px=pil_img.width,
height_px=pil_img.height,
**coords
)
pieces.append(piece_info)
groups.append(GroupInfo(group_id=group_id, pieces=pieces))
print(f"[PLT] 处理完成,共 {len(groups)} 组裁片")
return ProcessPltResponse(
success=True,
total_groups=len(groups),
groups=groups
)
except Exception as e:
print(f"[PLT] 处理失败: {str(e)}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}")
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", 8080))
uvicorn.run(app, host="0.0.0.0", port=port)

620
PltService/pltreader.py Normal file
View File

@@ -0,0 +1,620 @@
'''
参考资料:
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
import logging
from shapely.validation import make_valid
SHOW_PLT_WARNINGS = False
# 如果没配置日志则配置日志
if not logging.getLogger().handlers:
logging.basicConfig(level=logging.INFO, format='[%(asctime)s][%(name)s][%(levelname)s] %(message)s')
def assert_warning(condition, message):
if SHOW_PLT_WARNINGS and (not condition):
logging.warning(message)
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))
logging.info(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))
cv2.polylines(result_img, [pts], True, (0,0,0,255), 2)
# 画无关紧要的东西
multiPolygon = MultiPolygon([node["data"] for node in nodes])
multiPolygon = make_valid(multiPolygon.buffer(0))
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]
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]:
'''获取单码信息'''
logging.info(f"开始进行距离聚类,目标聚类数目: {size_num}")
# 计算所有多边形两两之间的距离
distances: list[tuple[float, int, int]] = []
for i in range(len(self.nodes)):
for j in range(i + 1, len(self.nodes)):
poly1 = self.nodes[i]["data"]
poly2 = self.nodes[j]["data"]
dist = poly1.distance(poly2)
distances.append((dist, i, j))
distances.sort(key=lambda x: x[0])
# 使用并查集进行聚类
parent = list(range(len(self.nodes)))
def find(i):
if parent[i] != i:
parent[i] = find(parent[i]) # 路径压缩
return parent[i]
def union(i, j):
root_i = find(i)
root_j = find(j)
parent[root_i] = root_j
return root_i != root_j
# 进行聚类直到聚类数目达到size_num
current_cluster_count = len(self.nodes)
for _, i, j in distances:
if current_cluster_count <= size_num:
break
if union(i, j):
current_cluster_count -= 1
# 构建聚类结果
clusters_dict = {}
for i in range(len(self.nodes)):
root = find(i)
if root not in clusters_dict:
clusters_dict[root] = []
clusters_dict[root].append(self.nodes[i])
clusters = list(clusters_dict.values())
logging.info(f"距离聚类得到{len(clusters)}个聚类")
# 排序
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) -> typing.List[np.ndarray]:
'''单片输出'''
result = []
for node in self.nodes:
# 如果是顶层则新建一副图像
if node["parent"] is None:
nodes = [node] + node['child']
result.append(self._draw_nodes(nodes, scale_factor))
return result
class _VirtualMachine(object):
'''运行PLT文件的虚拟机'''
def __init__(self, tolerance) -> None:
self.tolerance = tolerance
def __run_v2(self, abstract_syntax_tree: AbstractSyntaxTree) -> VirtualMachineOutput:
current_x, current_y = 0, 0
pen_state = 'UP'
raw_segments: typing.List[typing.List[typing.Tuple[float, float]]] = []
current_path: typing.List[typing.Tuple[float, float]] = []
for code in abstract_syntax_tree:
try:
if code["cmd"] == PltCommand.IN:
logging.info("初始化")
elif code["cmd"] == PltCommand.PA:
new_x = float(code["args"][0])
new_y = float(code["args"][1])
if pen_state == 'DOWN':
if not current_path:
current_path.append((current_x, current_y))
current_path.append((new_x, new_y))
elif pen_state == 'UP':
pass
# 更新当前位置
current_x, current_y = new_x, new_y
elif code["cmd"] == PltCommand.PU:
pen_state = 'UP'
if current_path:
assert len(current_path) > 1 and len(set(current_path)) > 1
raw_segments.append(current_path)
current_path = []
elif code["cmd"] == PltCommand.PD:
pen_state = 'DOWN'
except Exception as e:
assert_warning(False, f"[code {code}] 执行指令异常: {e}")
# 处理最后一条路径
if current_path:
assert len(current_path) > 1 and len(set(current_path)) > 1
raw_segments.append(current_path)
current_path = []
merged_geometry = shapely.ops.linemerge(raw_segments)
final_lines: typing.List[LineString] = []
if isinstance(merged_geometry, LineString):
final_lines.append(merged_geometry)
elif isinstance(merged_geometry, MultiLineString):
final_lines.extend(merged_geometry.geoms)
return self.__build_polygons_and_dispensable(final_lines)
def run(self, abstract_syntax_tree: AbstractSyntaxTree, version: int = 2) -> VirtualMachineOutput:
if version == 1:
logging.warning("版本1已过时 请使用版本2")
return self.__run_v1(abstract_syntax_tree)
elif version == 2:
return self.__run_v2(abstract_syntax_tree)
else:
raise ValueError(f"不支持的版本号: {version}")
def __run_v1(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:
logging.info("初始化")
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)
logging.info(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
logging.info("正在预处理PLT文件...")
preprocessor = _Preprocessor()
self.abstract_syntax_tree = preprocessor.get_abstract_syntax_tree(self.pltfile.read())
logging.info("预处理完成")
def get_output(self, tolerance=5, version: int = 2) -> VirtualMachineOutput:
machine = _VirtualMachine(tolerance=tolerance)
output = machine.run(self.abstract_syntax_tree, version)
return output
if __name__ == "__main__":
filepath = "标准.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"
for index, img in enumerate(output.single_size(size_num=5, scale_factor=72/1016)):
cv2.imwrite(os.path.join(single_size_path, f"{index}.png"), img)
print(f"单码输出已写入{single_size_path}")
single_piece_path = "single_piece"
for index, img in enumerate(output.single_piece(scale_factor=72/1016)):
cv2.imwrite(os.path.join(single_piece_path, f"{index}.png"), img)
print(f"单片输出已写入{single_piece_path}")

View File

@@ -0,0 +1,8 @@
fastapi==0.109.0
uvicorn==0.27.0
python-multipart==0.0.6
numpy==1.26.3
opencv-python-headless==4.9.0.80
shapely==2.0.2
scipy==1.12.0
Pillow==10.2.0