feat: AI套图分层方案 + Gemini集成 - 4种图案类型处理 + 正片叠底 + 宽高比 + 模型选择
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
32
PltService/Dockerfile
Normal file
32
PltService/Dockerfile
Normal 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
112
PltService/README.md
Normal 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 个 PLT(30秒) | ¥0.04 |
|
||||
| 每天 100 个 PLT | ¥4/天 |
|
||||
| 无请求时 | ¥0(最小实例设为0) |
|
||||
261
PltService/main.py
Normal file
261
PltService/main.py
Normal 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
620
PltService/pltreader.py
Normal 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}")
|
||||
|
||||
8
PltService/requirements.txt
Normal file
8
PltService/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user