第一次提交
This commit is contained in:
commit
1145dbfe49
8
.idea/.gitignore
vendored
Normal file
8
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
8
.idea/PictureEdit.iml
Normal file
8
.idea/PictureEdit.iml
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.10" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
7
.idea/inspectionProfiles/Project_Default.xml
Normal file
7
.idea/inspectionProfiles/Project_Default.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
6
.idea/inspectionProfiles/profiles_settings.xml
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
7
.idea/misc.xml
Normal file
7
.idea/misc.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.10" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10" project-jdk-type="Python SDK" />
|
||||
</project>
|
8
.idea/modules.xml
Normal file
8
.idea/modules.xml
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/PictureEdit.iml" filepath="$PROJECT_DIR$/.idea/PictureEdit.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
Normal file
6
.idea/vcs.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
277
canvas_widget.py
Normal file
277
canvas_widget.py
Normal file
@ -0,0 +1,277 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from PIL import Image, ImageTk
|
||||
from image_manager import ImageManager
|
||||
from transform_utils import TransformHelper
|
||||
import cv2
|
||||
|
||||
|
||||
class CanvasWidget(tk.Canvas):
|
||||
"""自定义画布组件,处理绘图和交互"""
|
||||
|
||||
def __init__(self, parent, project,main_window, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs, bg="#f0f0f0", highlightthickness=1, highlightbackground="#ccc")
|
||||
self.parent = parent # 这是Frame对象
|
||||
self.main_window = main_window # 这是MainWindow对象
|
||||
self.project = project
|
||||
self.selected_item = None
|
||||
self.dragging = False
|
||||
self.drag_data = {"x": 0, "y": 0}
|
||||
self.zoom = project.canvas_zoom
|
||||
|
||||
# 绑定事件
|
||||
self.bind("<Button-1>", self.on_click)
|
||||
self.bind("<B1-Motion>", self.on_drag)
|
||||
self.bind("<ButtonRelease-1>", self.on_release)
|
||||
self.bind("<MouseWheel>", self.on_zoom) # Windows
|
||||
self.bind("<Button-4>", self.on_zoom) # Linux
|
||||
self.bind("<Button-5>", self.on_zoom) # Linux
|
||||
|
||||
|
||||
self.active_handle = None # 当前激活的控制点
|
||||
|
||||
# 图像缓存
|
||||
self.image_cache = {}
|
||||
|
||||
# 初始绘制
|
||||
self.draw_preview()
|
||||
|
||||
def canvas_to_template(self, x, y):
|
||||
"""将画布坐标转换为模板坐标"""
|
||||
scale = self.zoom
|
||||
template_x = x / scale
|
||||
template_y = y / scale
|
||||
return template_x, template_y
|
||||
|
||||
def template_to_canvas(self, x, y):
|
||||
"""将模板坐标转换为画布坐标"""
|
||||
scale = self.zoom
|
||||
canvas_x = x * scale
|
||||
canvas_y = y * scale
|
||||
return canvas_x, canvas_y
|
||||
|
||||
# 修改 canvas_widget.py 中的 get_foreground_at 方法,优化碰撞检测
|
||||
def get_foreground_at(self, x, y):
|
||||
"""获取指定坐标处的前景图,优化碰撞检测"""
|
||||
template_x, template_y = self.canvas_to_template(x, y)
|
||||
|
||||
# 按图层顺序倒序检查(顶层优先)
|
||||
for fg in sorted(self.project.foregrounds, key=lambda f: -f.z_index):
|
||||
if not fg.visible:
|
||||
continue
|
||||
|
||||
# 更精确的碰撞检测(考虑变换后的顶点)
|
||||
min_x = min(v[0] for v in fg.vertices)
|
||||
max_x = max(v[0] for v in fg.vertices)
|
||||
min_y = min(v[1] for v in fg.vertices)
|
||||
max_y = max(v[1] for v in fg.vertices)
|
||||
|
||||
if min_x <= template_x <= max_x and min_y <= template_y <= max_y:
|
||||
return fg
|
||||
return None
|
||||
|
||||
# 修改 canvas_widget.py 中的 on_click 方法,添加控制点检测
|
||||
def on_click(self, event):
|
||||
"""处理鼠标点击事件,添加1. 检查是否点击了控制点
|
||||
2. 检查是否点击了图片
|
||||
3. 处理选择状态
|
||||
"""
|
||||
self.active_handle = None
|
||||
# 先检查是否点击了已选中项的控制点
|
||||
if self.selected_item:
|
||||
handles = TransformHelper.calculate_handles(self.selected_item, self)
|
||||
for handle in handles.values():
|
||||
if handle.hit_test(event.x, event.y):
|
||||
self.active_handle = handle.position
|
||||
# 保持选中状态
|
||||
self.dragging = True
|
||||
self.drag_data["x"] = event.x
|
||||
self.drag_data["y"] = event.y
|
||||
self.draw_preview()
|
||||
return
|
||||
|
||||
# 2. 检查是否点击了图片
|
||||
fg = self.get_foreground_at(event.x, event.y)
|
||||
if fg and not fg.locked:
|
||||
self.selected_item = fg
|
||||
self.drag_data["x"] = event.x
|
||||
self.drag_data["y"] = event.y
|
||||
self.dragging = True
|
||||
self.main_window.update_property_panel(fg)
|
||||
else:
|
||||
self.selected_item = None
|
||||
self.main_window.clear_property_panel()
|
||||
|
||||
self.draw_preview()
|
||||
|
||||
def on_drag(self, event):
|
||||
"""处理拖拽事件"""
|
||||
if self.dragging and self.selected_item:
|
||||
if self.active_handle:
|
||||
# 拖拽控制点
|
||||
dx = event.x - self.drag_data["x"]
|
||||
dy = event.y - self.drag_data["y"]
|
||||
self.selected_item = TransformHelper.update_vertices(
|
||||
self.selected_item, self.active_handle, dx, dy, self
|
||||
)
|
||||
else:
|
||||
# 拖拽整个图片
|
||||
dx = event.x - self.drag_data["x"]
|
||||
dy = event.y - self.drag_data["y"]
|
||||
t_dx, t_dy = self.canvas_to_template(dx, dy)
|
||||
|
||||
# 移动所有顶点
|
||||
new_vertices = []
|
||||
for x, y in self.selected_item.vertices:
|
||||
new_vertices.append((x + t_dx, y + t_dy))
|
||||
self.selected_item.vertices = new_vertices
|
||||
|
||||
# 更新拖拽起始位置
|
||||
self.drag_data["x"] = event.x
|
||||
self.drag_data["y"] = event.y
|
||||
|
||||
# 重绘
|
||||
self.draw_preview()
|
||||
# 更新属性面板
|
||||
self.main_window.update_property_panel(self.selected_item)
|
||||
|
||||
def on_release(self, event):
|
||||
"""处理鼠标释放事件"""
|
||||
self.dragging = False
|
||||
self.active_handle = None
|
||||
|
||||
def on_zoom(self, event):
|
||||
"""处理缩放事件"""
|
||||
# 计算缩放因子
|
||||
if event.delta > 0 or event.num == 4: # 放大
|
||||
self.zoom *= 1.1
|
||||
else: # 缩小
|
||||
self.zoom /= 1.1
|
||||
|
||||
# 限制缩放范围
|
||||
self.zoom = max(0.1, min(self.zoom, 5.0))
|
||||
|
||||
# 更新项目的缩放值
|
||||
self.project.canvas_zoom = self.zoom
|
||||
|
||||
# 重绘
|
||||
self.draw_preview()
|
||||
|
||||
def draw_preview(self):
|
||||
"""绘制预览内容"""
|
||||
self.delete("all")
|
||||
|
||||
# 获取模板尺寸
|
||||
template_w = self.project.template.width_px
|
||||
template_h = self.project.template.height_px
|
||||
|
||||
# 计算画布上的模板尺寸
|
||||
canvas_w = template_w * self.zoom
|
||||
canvas_h = template_h * self.zoom
|
||||
|
||||
# 调整画布滚动区域
|
||||
self.config(scrollregion=(0, 0, canvas_w, canvas_h))
|
||||
|
||||
# 绘制模板背景
|
||||
self.create_rectangle(0, 0, canvas_w, canvas_h, fill=self.project.template.bg_color)
|
||||
|
||||
# 绘制背景图(如果有)
|
||||
if self.project.template.bg_image:
|
||||
bg_key = id(self.project.template.bg_image)
|
||||
if bg_key not in self.image_cache or self.image_cache[bg_key][0] != self.zoom:
|
||||
# 缩放背景图并缓存
|
||||
scaled_bg = self.project.template.bg_image.resize(
|
||||
(int(canvas_w), int(canvas_h)),
|
||||
Image.Resampling.LANCZOS
|
||||
)
|
||||
self.image_cache[bg_key] = (self.zoom, ImageTk.PhotoImage(scaled_bg))
|
||||
|
||||
bg_img = self.image_cache[bg_key][1]
|
||||
self.create_image(0, 0, image=bg_img, anchor=tk.NW)
|
||||
|
||||
# 按图层顺序绘制前景图
|
||||
for fg in sorted(self.project.foregrounds, key=lambda f: f.z_index):
|
||||
# 跳过隐藏的图片
|
||||
if fg.hidden or not fg.visible:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 检查缓存 (使用顶点元组作为键的一部分)
|
||||
img_key = (fg.file_path, tuple(tuple(v) for v in fg.vertices), fg.angle, fg.mask_shape, self.zoom)
|
||||
if img_key not in self.image_cache:
|
||||
# 加载并处理图像
|
||||
img = ImageManager.load_image(fg.file_path)
|
||||
if img:
|
||||
# 应用遮罩
|
||||
masked_img = ImageManager.apply_mask(img, fg.mask_shape)
|
||||
|
||||
# 使用基于顶点的图像变形方法
|
||||
transformed_img, (x, y) = ImageManager.transform_with_vertices(
|
||||
masked_img, fg.vertices
|
||||
)
|
||||
|
||||
# 确保图像具有透明度
|
||||
if transformed_img.mode != 'RGBA':
|
||||
transformed_img = transformed_img.convert('RGBA')
|
||||
|
||||
# 缩放到预览尺寸
|
||||
scaled_img = transformed_img.resize(
|
||||
(max(1, int(transformed_img.width * self.zoom)),
|
||||
max(1, int(transformed_img.height * self.zoom))),
|
||||
Image.Resampling.LANCZOS
|
||||
)
|
||||
# 缓存图像
|
||||
self.image_cache[img_key] = ImageTk.PhotoImage(scaled_img)
|
||||
|
||||
# 获取画布坐标
|
||||
min_x = min(v[0] for v in fg.vertices)
|
||||
min_y = min(v[1] for v in fg.vertices)
|
||||
canvas_x, canvas_y = self.template_to_canvas(min_x, min_y)
|
||||
|
||||
# 绘制图像
|
||||
if img_key in self.image_cache:
|
||||
img = self.image_cache[img_key]
|
||||
# 使用包围盒左上角坐标绘制图像
|
||||
self.create_image(canvas_x, canvas_y, image=img, anchor=tk.NW)
|
||||
|
||||
# 如果是选中状态,绘制边框
|
||||
if self.selected_item == fg:
|
||||
# 绘制变形后的边框
|
||||
canvas_vertices = [
|
||||
self.template_to_canvas(x, y)
|
||||
for x, y in fg.vertices
|
||||
]
|
||||
# 绘制边框
|
||||
self.create_polygon(
|
||||
canvas_vertices,
|
||||
outline="blue", width=2, dash=(4, 2)
|
||||
)
|
||||
# 绘制控制点
|
||||
handles = TransformHelper.calculate_handles(fg, self)
|
||||
for handle in handles.values():
|
||||
self.create_oval(
|
||||
handle.x - handle.radius,
|
||||
handle.y - handle.radius,
|
||||
handle.x + handle.radius,
|
||||
handle.y + handle.radius,
|
||||
fill="white", outline="blue", width=2
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"绘制图像失败: {e}")
|
||||
# 绘制一个占位矩形
|
||||
min_x = min(v[0] for v in fg.vertices)
|
||||
min_y = min(v[1] for v in fg.vertices)
|
||||
canvas_x, canvas_y = self.template_to_canvas(min_x, min_y)
|
||||
# 计算宽度和高度
|
||||
width = max(v[0] for v in fg.vertices) - min_x
|
||||
height = max(v[1] for v in fg.vertices) - min_y
|
||||
canvas_w, canvas_h = self.template_to_canvas(width, height)
|
||||
self.create_rectangle(
|
||||
canvas_x, canvas_y,
|
||||
canvas_x + canvas_w, canvas_y + canvas_h,
|
||||
outline="red", fill="#ffeeee", dash=(2, 2)
|
||||
)
|
||||
self.create_text(
|
||||
canvas_x + canvas_w / 2, canvas_y + canvas_h / 2,
|
||||
text="无法加载", fill="red"
|
||||
)
|
153
export_dialog.py
Normal file
153
export_dialog.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""导出设置对话框"""
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox
|
||||
from pathlib import Path
|
||||
from exporter import Exporter
|
||||
|
||||
|
||||
class ExportDialog(tk.Toplevel):
|
||||
def __init__(self, parent, count):
|
||||
super().__init__(parent)
|
||||
self.title("导出设置")
|
||||
self.parent = parent
|
||||
self.count = count
|
||||
self.result = None
|
||||
|
||||
# 确保对话框模态
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
|
||||
# 设置默认导出路径为第一个图片所在目录下的output文件夹
|
||||
default_output_dir = str(Path.home())
|
||||
if parent.project.foregrounds:
|
||||
first_image_dir = Path(parent.project.foregrounds[0].file_path).parent
|
||||
default_output_dir = first_image_dir / "output"
|
||||
|
||||
# 设置布局
|
||||
frame = ttk.Frame(self, padding=10)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# 输出目录
|
||||
ttk.Label(frame, text="输出目录:").grid(row=0, column=0, sticky=tk.W, pady=5)
|
||||
self.output_dir_var = tk.StringVar(value=str(default_output_dir))
|
||||
ttk.Entry(frame, textvariable=self.output_dir_var, width=50).grid(row=0, column=1, pady=5)
|
||||
ttk.Button(frame, text="浏览", command=self.choose_output_dir).grid(row=0, column=2, padx=5, pady=5)
|
||||
|
||||
# 文件名模板
|
||||
ttk.Label(frame, text="文件名模板:").grid(row=1, column=0, sticky=tk.W, pady=5)
|
||||
self.filename_var = tk.StringVar(value="{name}_{idx}")
|
||||
ttk.Entry(frame, textvariable=self.filename_var, width=50).grid(row=1, column=1, pady=5)
|
||||
ttk.Label(frame, text="可用变量: {name}, {idx}, {total}, {date}").grid(row=1, column=2, pady=5)
|
||||
|
||||
# 输出格式
|
||||
ttk.Label(frame, text="输出格式:").grid(row=2, column=0, sticky=tk.W, pady=5)
|
||||
self.format_var = tk.StringVar(value="png")
|
||||
format_frame = ttk.Frame(frame)
|
||||
ttk.Radiobutton(format_frame, text="PNG", variable=self.format_var, value="png").pack(side=tk.LEFT, padx=5)
|
||||
|
||||
ttk.Radiobutton(format_frame, text="JPEG", variable=self.format_var, value="jpeg").pack(side=tk.LEFT, padx=5)
|
||||
format_frame.grid(row=2, column=1, sticky=tk.W, pady=5)
|
||||
|
||||
# 质量设置(仅JPEG)
|
||||
self.quality_frame = ttk.Frame(frame)
|
||||
ttk.Label(self.quality_frame, text="质量:").pack(side=tk.LEFT, padx=5)
|
||||
self.quality_var = tk.IntVar(value=95)
|
||||
ttk.Scale(
|
||||
self.quality_frame,
|
||||
from_=1,
|
||||
to=100,
|
||||
variable=self.quality_var,
|
||||
orient=tk.HORIZONTAL,
|
||||
length=200,
|
||||
command=lambda v: self.quality_label.config(text=f"{int(float(v))}%")
|
||||
).pack(side=tk.LEFT, padx=5)
|
||||
self.quality_label = ttk.Label(self.quality_frame, text="95%")
|
||||
self.quality_label.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# 进度条
|
||||
self.progress_var = tk.DoubleVar(value=0)
|
||||
self.progress_bar = ttk.Progressbar(frame, variable=self.progress_var, maximum=100)
|
||||
|
||||
# 按钮
|
||||
btn_frame = ttk.Frame(frame)
|
||||
self.export_btn = ttk.Button(btn_frame, text="导出", command=self.on_export)
|
||||
self.export_btn.pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(btn_frame, text="取消", command=self.on_cancel).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# 根据格式显示/隐藏质量设置
|
||||
self.update_quality_visibility()
|
||||
self.format_var.trace_add("write", lambda *args: self.update_quality_visibility())
|
||||
|
||||
# 布局管理
|
||||
self.quality_frame.grid(row=3, column=0, columnspan=3, sticky=tk.W, pady=5)
|
||||
self.progress_bar.grid(row=4, column=0, columnspan=2, sticky=tk.EW, pady=5)
|
||||
btn_frame.grid(row=5, column=0, columnspan=3, pady=10)
|
||||
|
||||
# 调整大小
|
||||
self.geometry("700x300")
|
||||
self.wait_window(self)
|
||||
|
||||
def choose_output_dir(self):
|
||||
"""选择输出目录"""
|
||||
dir_path = filedialog.askdirectory()
|
||||
if dir_path:
|
||||
self.output_dir_var.set(dir_path)
|
||||
|
||||
def update_quality_visibility(self):
|
||||
"""根据输出格式显示或隐藏质量设置"""
|
||||
if self.format_var.get().lower() == "jpeg":
|
||||
self.quality_frame.grid()
|
||||
else:
|
||||
self.quality_frame.grid_forget()
|
||||
|
||||
def update_progress(self, current, total):
|
||||
"""更新进度条"""
|
||||
progress = (current / total) * 100
|
||||
self.progress_var.set(progress)
|
||||
self.update_idletasks()
|
||||
|
||||
def on_export(self):
|
||||
"""执行导出"""
|
||||
# 禁用导出按钮防止重复点击
|
||||
self.export_btn.config(state=tk.DISABLED)
|
||||
|
||||
# 在新线程中执行导出,避免阻塞UI
|
||||
import threading
|
||||
export_thread = threading.Thread(target=self._perform_export)
|
||||
export_thread.daemon = True
|
||||
export_thread.start()
|
||||
|
||||
def _perform_export(self):
|
||||
"""在后台线程中执行导出"""
|
||||
output_dir = self.output_dir_var.get()
|
||||
filename_template = self.filename_var.get()
|
||||
format = self.format_var.get()
|
||||
quality = self.quality_var.get()
|
||||
|
||||
# 执行导出
|
||||
success = Exporter.export_batch(
|
||||
self.parent.project,
|
||||
output_dir,
|
||||
filename_template,
|
||||
format,
|
||||
quality,
|
||||
progress_callback=self.update_progress
|
||||
)
|
||||
|
||||
# 在主线程中更新UI
|
||||
self.after(0, self._on_export_complete, success)
|
||||
|
||||
def _on_export_complete(self, success):
|
||||
"""导出完成后的回调"""
|
||||
if success:
|
||||
messagebox.showinfo("成功", f"已成功导出 {self.count} 张图片")
|
||||
self.result = True
|
||||
self.destroy()
|
||||
else:
|
||||
messagebox.showerror("错误", "导出过程中发生错误")
|
||||
self.export_btn.config(state=tk.NORMAL)
|
||||
|
||||
def on_cancel(self):
|
||||
"""取消导出"""
|
||||
self.result = None
|
||||
self.destroy()
|
104
exporter.py
Normal file
104
exporter.py
Normal file
@ -0,0 +1,104 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from PIL import Image
|
||||
from image_manager import ImageManager
|
||||
import os
|
||||
|
||||
|
||||
class Exporter:
|
||||
"""导出器,处理批量导出功能"""
|
||||
|
||||
@staticmethod
|
||||
def export_batch(project, output_dir=None, filename_template="{name}_{idx}",
|
||||
format='png', quality=95, keep_exif=False, progress_callback=None):
|
||||
"""批量导出所有前景图"""
|
||||
# 如果没有指定输出目录,默认在第一个图片所在目录下创建output文件夹
|
||||
if output_dir is None and project.foregrounds:
|
||||
first_image_dir = Path(project.foregrounds[0].file_path).parent
|
||||
output_dir = first_image_dir / "output"
|
||||
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tasks = []
|
||||
for idx, fg in enumerate(project.foregrounds):
|
||||
base_name = Path(fg.file_path).stem
|
||||
filename = filename_template.format(
|
||||
name=base_name,
|
||||
idx=idx + 1,
|
||||
total=len(project.foregrounds),
|
||||
date=datetime.now().strftime("%Y%m%d")
|
||||
)
|
||||
file_path = output_dir / f"{filename}.{format.lower()}"
|
||||
tasks.append((fg, file_path))
|
||||
|
||||
# 使用线程池执行导出任务,限制最大线程数为4,避免系统卡顿
|
||||
max_workers = min(4, os.cpu_count() or 1) # 最多使用4个线程,或者CPU核心数(取较小值)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = []
|
||||
for fg, path in tasks:
|
||||
future = executor.submit(
|
||||
Exporter._export_single,
|
||||
project.template, fg, path, format, quality, keep_exif
|
||||
)
|
||||
future.add_done_callback(lambda f, i=len(futures):
|
||||
progress_callback(i + 1, len(tasks)) if progress_callback else None)
|
||||
futures.append(future)
|
||||
|
||||
# 检查是否有错误
|
||||
errors = []
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
result = future.result(timeout=30) # 设置30秒超时
|
||||
if not result:
|
||||
errors.append(1)
|
||||
except Exception as e:
|
||||
print(f"导出错误: {str(e)}")
|
||||
errors.append(1)
|
||||
|
||||
return len(errors) == 0
|
||||
|
||||
@staticmethod
|
||||
def _export_single(template, foreground, output_path, format, quality, keep_exif):
|
||||
"""导出单个前景图"""
|
||||
try:
|
||||
# 加载原图
|
||||
img = ImageManager.load_image(foreground.file_path)
|
||||
if img is None:
|
||||
return False
|
||||
|
||||
# 应用遮罩
|
||||
masked_img = ImageManager.apply_mask(img, foreground.mask_shape)
|
||||
|
||||
# 使用基于顶点的图像变形方法
|
||||
transformed_img, (x, y) = ImageManager.transform_with_vertices(
|
||||
masked_img, foreground.vertices
|
||||
)
|
||||
|
||||
# 创建模板大小的画布
|
||||
canvas = Image.new('RGBA', (template.width_px, template.height_px),
|
||||
color=template.bg_color + "FF") # 添加不透明度
|
||||
|
||||
# 绘制背景图
|
||||
if template.bg_image:
|
||||
# 缩放背景图以适应模板
|
||||
bg_scaled = template.bg_image.resize(
|
||||
(template.width_px, template.height_px),
|
||||
Image.Resampling.LANCZOS
|
||||
)
|
||||
canvas.paste(bg_scaled, (0, 0))
|
||||
|
||||
# 粘贴前景图
|
||||
# 确保transformed_img具有RGBA模式
|
||||
if transformed_img.mode != 'RGBA':
|
||||
transformed_img = transformed_img.convert('RGBA')
|
||||
|
||||
canvas.paste(transformed_img, (int(x), int(y)), transformed_img)
|
||||
|
||||
# 保存图像
|
||||
return ImageManager.save_image(canvas, str(output_path), format, quality, keep_exif)
|
||||
|
||||
except Exception as e:
|
||||
print(f"导出单个图像失败: {e}")
|
||||
return False
|
223
image_manager.py
Normal file
223
image_manager.py
Normal file
@ -0,0 +1,223 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageOps
|
||||
|
||||
|
||||
class ImageManager:
|
||||
"""图像管理工具类,处理图像加载、变换和保存"""
|
||||
|
||||
@staticmethod
|
||||
def load_image(file_path):
|
||||
"""加载图像并转换为RGBA格式"""
|
||||
try:
|
||||
return Image.open(file_path).convert("RGBA")
|
||||
except Exception as e:
|
||||
print(f"加载图像失败: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def apply_mask(image, shape):
|
||||
"""应用遮罩到图像"""
|
||||
if shape == "rect":
|
||||
return image # 矩形不需要遮罩
|
||||
|
||||
# 创建与图像相同大小的透明遮罩
|
||||
mask = Image.new('L', image.size, 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
|
||||
# 根据形状绘制遮罩
|
||||
if shape == "round":
|
||||
# 圆形遮罩
|
||||
draw.ellipse((0, 0, image.size[0], image.size[1]), fill=255)
|
||||
elif shape == "heart":
|
||||
# 心形遮罩(简化版)
|
||||
x, y = image.size
|
||||
points = [
|
||||
(x // 2, y // 5),
|
||||
(x // 5, y // 2),
|
||||
(x // 5, y * 3 // 4),
|
||||
(x // 2, y * 4 // 5),
|
||||
(x * 4 // 5, y * 3 // 4),
|
||||
(x * 4 // 5, y // 2),
|
||||
(x // 2, y // 5)
|
||||
]
|
||||
draw.polygon(points, fill=255)
|
||||
elif shape == "star":
|
||||
# 星形遮罩(简化版)
|
||||
x, y = image.size
|
||||
center_x, center_y = x // 2, y // 2
|
||||
radius = min(center_x, center_y)
|
||||
points = []
|
||||
for i in range(10):
|
||||
angle = 0.1 * i * 3.14159
|
||||
r = radius if i % 2 == 0 else radius * 0.4
|
||||
points.append((
|
||||
center_x + r * (1 if i % 4 < 2 else -1) * abs(angle % (2 * 3.14159) - 3.14159 / 2) ** 0.5,
|
||||
center_y + r * (1 if i < 5 else -1) * abs(angle % (2 * 3.14159) - 3.14159) ** 0.5
|
||||
))
|
||||
draw.polygon(points, fill=255)
|
||||
|
||||
# 应用遮罩
|
||||
return Image.composite(image, Image.new('RGBA', image.size, (0, 0, 0, 0)), mask)
|
||||
|
||||
@staticmethod
|
||||
def transform_image(image, x, y, width, height, angle):
|
||||
"""应用缩放、旋转和位置变换"""
|
||||
import math
|
||||
|
||||
# 确保宽度和高度大于0
|
||||
width = max(1, width)
|
||||
height = max(1, height)
|
||||
|
||||
# 缩放
|
||||
scaled = image.resize((int(width), int(height)), Image.Resampling.LANCZOS)
|
||||
|
||||
# 旋转
|
||||
if angle != 0:
|
||||
# 创建一个更大的画布以容纳旋转后的图像
|
||||
rotated_size = int(math.sqrt(width**2 + height**2)) * 2
|
||||
temp_canvas = Image.new('RGBA', (rotated_size, rotated_size), (0, 0, 0, 0))
|
||||
|
||||
# 将缩放后的图像居中放置在临时画布上
|
||||
temp_x = (rotated_size - scaled.width) // 2
|
||||
temp_y = (rotated_size - scaled.height) // 2
|
||||
temp_canvas.paste(scaled, (temp_x, temp_y))
|
||||
|
||||
# 旋转
|
||||
rotated = temp_canvas.rotate(angle, expand=True, resample=Image.Resampling.BILINEAR)
|
||||
|
||||
# 计算新的偏移量
|
||||
offset_x = x - (rotated_size - scaled.width) // 2
|
||||
offset_y = y - (rotated_size - scaled.height) // 2
|
||||
|
||||
return rotated, (offset_x, offset_y)
|
||||
else:
|
||||
# 如果没有旋转,直接返回缩放后的图像和位置
|
||||
return scaled, (x, y)
|
||||
|
||||
@staticmethod
|
||||
def perspective_transform(image, vertices, output_size):
|
||||
"""应用透视变换,将图像映射到指定的四边形顶点"""
|
||||
try:
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
# 获取图像尺寸
|
||||
img_width, img_height = image.size
|
||||
|
||||
# 源坐标:图像的四个角
|
||||
src_points = np.float32([
|
||||
[0, 0],
|
||||
[img_width, 0],
|
||||
[0, img_height],
|
||||
[img_width, img_height]
|
||||
])
|
||||
|
||||
# 目标坐标:指定的四个顶点
|
||||
# 确保顶点坐标是列表而不是元组
|
||||
dst_points = np.float32([list(v) for v in vertices])
|
||||
|
||||
# 计算透视变换矩阵
|
||||
matrix = cv2.getPerspectiveTransform(src_points, dst_points)
|
||||
|
||||
# 计算输出图像的尺寸
|
||||
output_width, output_height = output_size
|
||||
|
||||
# 应用透视变换
|
||||
result = cv2.warpPerspective(
|
||||
np.array(image),
|
||||
matrix,
|
||||
(output_width, output_height),
|
||||
borderMode=cv2.BORDER_CONSTANT,
|
||||
borderValue=(0, 0, 0, 0) # 透明背景
|
||||
)
|
||||
|
||||
# 转换回PIL图像
|
||||
return Image.fromarray(result)
|
||||
except ImportError:
|
||||
# 如果没有安装opencv,返回原始图像
|
||||
return image
|
||||
|
||||
@staticmethod
|
||||
def transform_with_vertices(image, vertices):
|
||||
"""根据顶点坐标对图像进行变形"""
|
||||
try:
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
# 获取图像尺寸
|
||||
img_width, img_height = image.size
|
||||
|
||||
# 源坐标:图像的四个角
|
||||
src_points = np.float32([
|
||||
[0, 0],
|
||||
[img_width, 0],
|
||||
[0, img_height],
|
||||
[img_width, img_height]
|
||||
])
|
||||
|
||||
# 目标坐标:指定的四个顶点
|
||||
dst_points = np.float32([list(v) for v in vertices])
|
||||
|
||||
# 计算包围盒
|
||||
min_x = min(v[0] for v in dst_points)
|
||||
min_y = min(v[1] for v in dst_points)
|
||||
max_x = max(v[0] for v in dst_points)
|
||||
max_y = max(v[1] for v in dst_points)
|
||||
|
||||
# 调整目标坐标为相对于包围盒的坐标
|
||||
adjusted_dst_points = dst_points - np.array([min_x, min_y])
|
||||
|
||||
# 计算输出尺寸
|
||||
output_width = int(max_x - min_x)
|
||||
output_height = int(max_y - min_y)
|
||||
|
||||
# 确保输出尺寸大于0
|
||||
output_width = max(1, output_width)
|
||||
output_height = max(1, output_height)
|
||||
|
||||
# 计算透视变换矩阵
|
||||
matrix = cv2.getPerspectiveTransform(src_points, adjusted_dst_points)
|
||||
|
||||
# 应用透视变换,使用透明背景
|
||||
result = cv2.warpPerspective(
|
||||
np.array(image),
|
||||
matrix,
|
||||
(output_width, output_height),
|
||||
borderMode=cv2.BORDER_CONSTANT,
|
||||
borderValue=(0, 0, 0, 0) # 透明背景而不是黑色背景
|
||||
)
|
||||
|
||||
# 转换回PIL图像
|
||||
if len(result.shape) == 3 and result.shape[2] == 4: # 如果有alpha通道
|
||||
return Image.fromarray(result), (min_x, min_y)
|
||||
else: # 如果没有alpha通道
|
||||
return Image.fromarray(result).convert('RGBA'), (min_x, min_y)
|
||||
except ImportError:
|
||||
# 如果没有安装opencv,使用简单的变换
|
||||
min_x = min(v[0] for v in vertices)
|
||||
min_y = min(v[1] for v in vertices)
|
||||
# 确保返回的图像具有RGBA模式
|
||||
if image.mode != 'RGBA':
|
||||
image = image.convert('RGBA')
|
||||
return image, (min_x, min_y)
|
||||
|
||||
@staticmethod
|
||||
def save_image(image, file_path, format='png', quality=95, keep_exif=False):
|
||||
"""保存图像到文件"""
|
||||
try:
|
||||
# 如果是JPEG格式,转换为RGB
|
||||
if format.lower() == 'jpeg':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# 保存图像
|
||||
image.save(
|
||||
file_path,
|
||||
format=format.upper(),
|
||||
quality=quality,
|
||||
exif=image.info.get('exif') if keep_exif else None
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"保存图像失败: {e}")
|
||||
return False
|
6
main.py
Normal file
6
main.py
Normal file
@ -0,0 +1,6 @@
|
||||
import tkinter as tk
|
||||
from main_window import MainWindow
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = MainWindow()
|
||||
app.mainloop()
|
523
main_window.py
Normal file
523
main_window.py
Normal file
@ -0,0 +1,523 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog, simpledialog
|
||||
from pathlib import Path
|
||||
from models import Project, Template
|
||||
from canvas_widget import CanvasWidget
|
||||
from template_dialog import TemplateDialog
|
||||
from export_dialog import ExportDialog
|
||||
from exporter import Exporter
|
||||
|
||||
# 确保中文显示正常
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
plt.rcParams["font.family"] = ["SimHei", "WenQuanYi Micro Hei", "Heiti TC"]
|
||||
|
||||
|
||||
class MainWindow(tk.Tk):
|
||||
"""主窗口"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("批量图片布局工具")
|
||||
self.project = Project()
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""初始化UI"""
|
||||
# 设置样式
|
||||
style = ttk.Style()
|
||||
style.configure("TButton", padding=5)
|
||||
style.configure("TLabel", padding=2)
|
||||
|
||||
# 菜单栏
|
||||
self.create_menu()
|
||||
|
||||
# 工具栏
|
||||
toolbar = ttk.Frame(self, padding=5)
|
||||
toolbar.pack(fill=tk.X)
|
||||
|
||||
ttk.Button(toolbar, text="打开背景", command=self.open_background).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(toolbar, text="添加图片", command=self.add_foregrounds).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(toolbar, text="清除图片", command=self.clear_foregrounds).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(toolbar, text="模板设置", command=self.set_template).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(toolbar, text="批量导出", command=self.batch_export).pack(side=tk.LEFT, padx=2)
|
||||
|
||||
# 主布局
|
||||
main_frame = ttk.Frame(self, padding=5)
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# 左侧:图片列表
|
||||
left_frame = ttk.Frame(main_frame, width=200)
|
||||
left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 5))
|
||||
|
||||
ttk.Label(left_frame, text="前景图片列表", font=("Arial", 10, "bold")).pack(anchor=tk.W, pady=5)
|
||||
|
||||
self.image_list_frame = ttk.Frame(left_frame)
|
||||
self.image_list_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# 滚动条
|
||||
scrollbar = ttk.Scrollbar(self.image_list_frame)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
self.image_listbox = tk.Listbox(
|
||||
self.image_list_frame,
|
||||
yscrollcommand=scrollbar.set,
|
||||
selectmode=tk.SINGLE,
|
||||
height=20
|
||||
)
|
||||
self.image_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scrollbar.config(command=self.image_listbox.yview)
|
||||
|
||||
# 绑定列表事件
|
||||
self.image_listbox.bind('<<ListboxSelect>>', self.on_image_select)
|
||||
|
||||
# 添加按钮
|
||||
ttk.Button(left_frame, text="添加图片", command=self.add_foregrounds).pack(fill=tk.X, pady=5)
|
||||
ttk.Button(left_frame, text="移除选中", command=self.remove_selected).pack(fill=tk.X)
|
||||
|
||||
# 中间:画布区域
|
||||
center_frame = ttk.Frame(main_frame)
|
||||
center_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# 画布滚动条
|
||||
hscroll = ttk.Scrollbar(center_frame, orient=tk.HORIZONTAL)
|
||||
hscroll.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
vscroll = ttk.Scrollbar(center_frame, orient=tk.VERTICAL)
|
||||
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# 创建画布
|
||||
self.canvas = CanvasWidget(center_frame, self.project, main_window=self)
|
||||
# self.canvas = CanvasWidget(center_frame, self.project)
|
||||
self.canvas.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# 关联滚动条
|
||||
self.canvas.config(xscrollcommand=hscroll.set, yscrollcommand=vscroll.set)
|
||||
hscroll.config(command=self.canvas.xview)
|
||||
vscroll.config(command=self.canvas.yview)
|
||||
|
||||
# 右侧:属性面板
|
||||
right_frame = ttk.Frame(main_frame, width=200)
|
||||
right_frame.pack(side=tk.RIGHT, fill=tk.Y, padx=(5, 0))
|
||||
|
||||
ttk.Label(right_frame, text="属性设置", font=("Arial", 10, "bold")).pack(anchor=tk.W, pady=5)
|
||||
|
||||
self.property_frame = ttk.Frame(right_frame)
|
||||
self.property_frame.pack(fill=tk.BOTH, expand=True, padx=5)
|
||||
|
||||
# 初始化属性面板内容
|
||||
self.clear_property_panel()
|
||||
|
||||
# 设置窗口大小
|
||||
self.geometry("1200x700")
|
||||
|
||||
def create_menu(self):
|
||||
"""创建菜单栏"""
|
||||
menubar = tk.Menu(self)
|
||||
|
||||
# 文件菜单
|
||||
file_menu = tk.Menu(menubar, tearoff=0)
|
||||
file_menu.add_command(label="新建项目", command=self.new_project)
|
||||
file_menu.add_command(label="打开项目", command=self.open_project)
|
||||
file_menu.add_command(label="保存项目", command=self.save_project)
|
||||
file_menu.add_command(label="另存为", command=self.save_project_as)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_command(label="退出", command=self.quit)
|
||||
menubar.add_cascade(label="文件", menu=file_menu)
|
||||
|
||||
# 编辑菜单
|
||||
edit_menu = tk.Menu(menubar, tearoff=0)
|
||||
edit_menu.add_command(label="清除所有图片", command=self.clear_foregrounds)
|
||||
edit_menu.add_separator()
|
||||
edit_menu.add_command(label="上移图层", command=lambda: self.move_layer(1))
|
||||
edit_menu.add_command(label="下移图层", command=lambda: self.move_layer(-1))
|
||||
menubar.add_cascade(label="编辑", menu=edit_menu)
|
||||
|
||||
# 视图菜单
|
||||
view_menu = tk.Menu(menubar, tearoff=0)
|
||||
view_menu.add_command(label="缩放重置", command=self.reset_zoom)
|
||||
menubar.add_cascade(label="视图", menu=view_menu)
|
||||
|
||||
# 帮助菜单
|
||||
help_menu = tk.Menu(menubar, tearoff=0)
|
||||
help_menu.add_command(label="关于", command=self.show_about)
|
||||
menubar.add_cascade(label="帮助", menu=help_menu)
|
||||
|
||||
# 设置菜单栏
|
||||
self.config(menu=menubar)
|
||||
|
||||
def open_background(self):
|
||||
"""打开背景图"""
|
||||
file_path = filedialog.askopenfilename(
|
||||
filetypes=[
|
||||
("图像文件", "*.jpg;*.jpeg;*.png;*.webp;*.bmp"),
|
||||
("所有文件", "*.*")
|
||||
]
|
||||
)
|
||||
if file_path:
|
||||
if self.project.template.load_background(file_path):
|
||||
self.canvas.draw_preview()
|
||||
|
||||
def add_foregrounds(self):
|
||||
"""添加前景图"""
|
||||
file_paths = filedialog.askopenfilenames(
|
||||
filetypes=[
|
||||
("图像文件", "*.jpg;*.jpeg;*.png;*.webp;*.bmp"),
|
||||
("所有文件", "*.*")
|
||||
]
|
||||
)
|
||||
|
||||
if file_paths:
|
||||
# 检查是否是第一次添加图片
|
||||
is_first_batch = len(self.project.foregrounds) == 0
|
||||
|
||||
# 添加所有选择的图片
|
||||
added_foregrounds = []
|
||||
for file_path in file_paths:
|
||||
# 检查是否已添加
|
||||
if not any(fg.file_path == file_path for fg in self.project.foregrounds):
|
||||
fg = self.project.add_foreground(file_path)
|
||||
added_foregrounds.append(fg)
|
||||
|
||||
# 如果是第一次批量添加,则设置批量处理模式
|
||||
if is_first_batch and len(added_foregrounds) > 1:
|
||||
# 第一张图片保持可见和可编辑
|
||||
first_fg = added_foregrounds[0]
|
||||
first_fg.hidden = False
|
||||
first_fg.visible = True
|
||||
|
||||
# 其他图片设置为隐藏
|
||||
for fg in added_foregrounds[1:]:
|
||||
fg.hidden = True
|
||||
fg.visible = False
|
||||
|
||||
# 选中第一张图片
|
||||
self.canvas.selected_item = first_fg
|
||||
self.update_property_panel(first_fg)
|
||||
elif not is_first_batch and self.project.foregrounds:
|
||||
# 如果不是第一次添加,且已存在图片,则复制第一张图片的配置
|
||||
reference_fg = self.project.foregrounds[0]
|
||||
for fg in added_foregrounds:
|
||||
fg.vertices = [tuple(v) for v in reference_fg.vertices]
|
||||
fg.angle = reference_fg.angle
|
||||
fg.mask_shape = reference_fg.mask_shape
|
||||
fg.locked = reference_fg.locked
|
||||
fg.visible = reference_fg.visible
|
||||
fg.hidden = reference_fg.hidden
|
||||
|
||||
# 更新列表
|
||||
self.update_image_list()
|
||||
# 重绘画布
|
||||
self.canvas.draw_preview()
|
||||
|
||||
def update_image_list(self):
|
||||
"""更新图片列表"""
|
||||
self.image_listbox.delete(0, tk.END)
|
||||
for fg in self.project.foregrounds:
|
||||
name = Path(fg.file_path).name
|
||||
self.image_listbox.insert(tk.END, name)
|
||||
|
||||
def on_image_select(self, event):
|
||||
"""处理列表选择事件"""
|
||||
selection = self.image_listbox.curselection()
|
||||
if selection:
|
||||
index = selection[0]
|
||||
if 0 <= index < len(self.project.foregrounds):
|
||||
fg = self.project.foregrounds[index]
|
||||
self.canvas.selected_item = fg
|
||||
self.update_property_panel(fg)
|
||||
self.canvas.draw_preview()
|
||||
else:
|
||||
# 如果没有选择任何项目,显示默认面板
|
||||
self.clear_property_panel()
|
||||
|
||||
def remove_selected(self):
|
||||
"""移除选中的图片"""
|
||||
selection = self.image_listbox.curselection()
|
||||
if selection:
|
||||
index = selection[0]
|
||||
if 0 <= index < len(self.project.foregrounds):
|
||||
fg = self.project.foregrounds[index]
|
||||
self.project.remove_foreground(fg)
|
||||
self.update_image_list()
|
||||
self.clear_property_panel()
|
||||
self.canvas.selected_item = None
|
||||
self.canvas.draw_preview()
|
||||
|
||||
def clear_foregrounds(self):
|
||||
"""清除所有前景图"""
|
||||
if messagebox.askyesno("确认", "确定要清除所有前景图片吗?"):
|
||||
self.project.foregrounds.clear()
|
||||
self.update_image_list()
|
||||
self.clear_property_panel()
|
||||
self.canvas.selected_item = None
|
||||
self.canvas.draw_preview()
|
||||
|
||||
def get_canvas(self):
|
||||
"""获取画布组件"""
|
||||
return self.canvas
|
||||
|
||||
def set_template(self):
|
||||
"""设置模板"""
|
||||
dialog = TemplateDialog(self, self.project.template)
|
||||
if dialog.result:
|
||||
if dialog.result["type"] == "preset":
|
||||
self.project.template = Template(
|
||||
dialog.result["ratio"],
|
||||
dialog.result["width"]
|
||||
)
|
||||
else:
|
||||
self.project.template = Template()
|
||||
self.project.template.set_custom_size(
|
||||
dialog.result["width"],
|
||||
dialog.result["height"]
|
||||
)
|
||||
|
||||
self.project.template.bg_color = dialog.result["bg_color"]
|
||||
self.canvas.draw_preview()
|
||||
|
||||
def batch_export(self):
|
||||
"""批量导出"""
|
||||
if not self.project.foregrounds:
|
||||
messagebox.showinfo("提示", "请先添加前景图片")
|
||||
return
|
||||
|
||||
# 在导出前,将第一张图片的配置应用到所有隐藏的图片
|
||||
if self.project.foregrounds:
|
||||
first_fg = self.project.foregrounds[0]
|
||||
# 将第一张图片的配置应用到所有隐藏的图片
|
||||
for fg in self.project.foregrounds[1:]:
|
||||
if fg.hidden:
|
||||
fg.vertices = [tuple(v) for v in first_fg.vertices]
|
||||
fg.angle = first_fg.angle
|
||||
fg.mask_shape = first_fg.mask_shape
|
||||
|
||||
# 设置默认导出路径为第一个图片所在目录下的output文件夹
|
||||
default_output_dir = None
|
||||
if self.project.foregrounds:
|
||||
first_image_dir = Path(self.project.foregrounds[0].file_path).parent
|
||||
default_output_dir = first_image_dir / "output"
|
||||
|
||||
dialog = ExportDialog(self, len(self.project.foregrounds))
|
||||
if dialog.result:
|
||||
# 禁用主窗口防止重复操作
|
||||
self.grab_set()
|
||||
try:
|
||||
# 执行导出
|
||||
success = Exporter.export_batch(
|
||||
self.project,
|
||||
dialog.output_dir_var.get(),
|
||||
dialog.filename_var.get(),
|
||||
dialog.format_var.get(),
|
||||
dialog.quality_var.get(),
|
||||
progress_callback=self.update_export_progress
|
||||
)
|
||||
|
||||
# 导出完成
|
||||
if success:
|
||||
messagebox.showinfo("成功", f"已成功导出 {len(self.project.foregrounds)} 张图片")
|
||||
else:
|
||||
messagebox.showerror("错误", "导出过程中发生错误")
|
||||
finally:
|
||||
# 重新启用主窗口
|
||||
self.grab_release()
|
||||
|
||||
def update_export_progress(self, current, total):
|
||||
"""更新导出进度(这个方法需要在ExportDialog中实现)"""
|
||||
# 注意:这个方法需要在ExportDialog中调用,而不是直接在这里实现
|
||||
pass
|
||||
|
||||
def update_property_panel(self, fg):
|
||||
"""更新属性面板"""
|
||||
# 清除现有内容
|
||||
for widget in self.property_frame.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
# 位置设置
|
||||
ttk.Label(self.property_frame, text="位置", font=("Arial", 9, "bold")).pack(anchor=tk.W, pady=5)
|
||||
|
||||
pos_frame = ttk.Frame(self.property_frame)
|
||||
pos_frame.pack(fill=tk.X, pady=2)
|
||||
|
||||
ttk.Label(pos_frame, text="X:").grid(row=0, column=0, sticky=tk.W)
|
||||
x_var = tk.DoubleVar(value=fg.x)
|
||||
x_entry = ttk.Entry(pos_frame, textvariable=x_var, width=10)
|
||||
x_entry.grid(row=0, column=1, padx=2)
|
||||
x_entry.bind("<FocusOut>", lambda e: self.update_property(fg, "x", x_var.get()))
|
||||
|
||||
ttk.Label(pos_frame, text="Y:").grid(row=0, column=2, sticky=tk.W, padx=5)
|
||||
y_var = tk.DoubleVar(value=fg.y)
|
||||
y_entry = ttk.Entry(pos_frame, textvariable=y_var, width=10)
|
||||
y_entry.grid(row=0, column=3, padx=2)
|
||||
y_entry.bind("<FocusOut>", lambda e: self.update_property(fg, "y", y_var.get()))
|
||||
|
||||
# 尺寸设置
|
||||
ttk.Label(self.property_frame, text="尺寸", font=("Arial", 9, "bold")).pack(anchor=tk.W, pady=5)
|
||||
|
||||
size_frame = ttk.Frame(self.property_frame)
|
||||
size_frame.pack(fill=tk.X, pady=2)
|
||||
|
||||
ttk.Label(size_frame, text="宽:").grid(row=0, column=0, sticky=tk.W)
|
||||
w_var = tk.DoubleVar(value=fg.w)
|
||||
w_entry = ttk.Entry(size_frame, textvariable=w_var, width=10)
|
||||
w_entry.grid(row=0, column=1, padx=2)
|
||||
w_entry.bind("<FocusOut>", lambda e: self.update_property(fg, "w", w_var.get()))
|
||||
|
||||
ttk.Label(size_frame, text="高:").grid(row=0, column=2, sticky=tk.W, padx=5)
|
||||
h_var = tk.DoubleVar(value=fg.h)
|
||||
h_entry = ttk.Entry(size_frame, textvariable=h_var, width=10)
|
||||
h_entry.grid(row=0, column=3, padx=2)
|
||||
h_entry.bind("<FocusOut>", lambda e: self.update_property(fg, "h", h_var.get()))
|
||||
|
||||
# 旋转设置
|
||||
ttk.Label(self.property_frame, text="旋转", font=("Arial", 9, "bold")).pack(anchor=tk.W, pady=5)
|
||||
|
||||
rot_frame = ttk.Frame(self.property_frame)
|
||||
rot_frame.pack(fill=tk.X, pady=2)
|
||||
|
||||
angle_var = tk.DoubleVar(value=fg.angle)
|
||||
angle_entry = ttk.Entry(rot_frame, textvariable=angle_var, width=10)
|
||||
angle_entry.grid(row=0, column=0, padx=2)
|
||||
ttk.Label(rot_frame, text="度").grid(row=0, column=1, sticky=tk.W)
|
||||
angle_entry.bind("<FocusOut>", lambda e: self.update_property(fg, "angle", angle_var.get()))
|
||||
|
||||
# 遮罩设置
|
||||
ttk.Label(self.property_frame, text="遮罩", font=("Arial", 9, "bold")).pack(anchor=tk.W, pady=5)
|
||||
|
||||
mask_frame = ttk.Frame(self.property_frame)
|
||||
mask_frame.pack(fill=tk.X, pady=2)
|
||||
|
||||
mask_var = tk.StringVar(value=fg.mask_shape)
|
||||
masks = [("矩形", "rect"), ("圆形", "round"), ("心形", "heart"), ("星形", "star")]
|
||||
for i, (text, value) in enumerate(masks):
|
||||
ttk.Radiobutton(
|
||||
mask_frame,
|
||||
text=text,
|
||||
variable=mask_var,
|
||||
value=value,
|
||||
command=lambda: self.update_property(fg, "mask_shape", mask_var.get())
|
||||
).grid(row=i // 2, column=i % 2, sticky=tk.W, padx=2, pady=2)
|
||||
|
||||
# 其他设置
|
||||
ttk.Label(self.property_frame, text="其他", font=("Arial", 9, "bold")).pack(anchor=tk.W, pady=5)
|
||||
|
||||
lock_var = tk.BooleanVar(value=fg.locked)
|
||||
ttk.Checkbutton(
|
||||
self.property_frame,
|
||||
text="锁定位置",
|
||||
variable=lock_var,
|
||||
command=lambda: self.update_property(fg, "locked", lock_var.get())
|
||||
).pack(anchor=tk.W, pady=2)
|
||||
|
||||
visible_var = tk.BooleanVar(value=fg.visible)
|
||||
ttk.Checkbutton(
|
||||
self.property_frame,
|
||||
text="显示图片",
|
||||
variable=visible_var,
|
||||
command=lambda: self.update_property(fg, "visible", visible_var.get())
|
||||
).pack(anchor=tk.W, pady=2)
|
||||
|
||||
def update_property(self, fg, prop, value):
|
||||
"""更新属性值"""
|
||||
if hasattr(fg, prop):
|
||||
setattr(fg, prop, value)
|
||||
self.canvas.draw_preview()
|
||||
|
||||
def clear_property_panel(self):
|
||||
"""清除属性面板"""
|
||||
for widget in self.property_frame.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
ttk.Label(self.property_frame, text="未选择图片", foreground="#999").pack(pady=20)
|
||||
|
||||
def move_layer(self, direction):
|
||||
"""移动图层顺序"""
|
||||
if not self.canvas.selected_item:
|
||||
return
|
||||
|
||||
fg = self.canvas.selected_item
|
||||
current_idx = fg.z_index
|
||||
new_idx = current_idx + direction
|
||||
|
||||
# 检查边界
|
||||
if new_idx < 0 or new_idx >= len(self.project.foregrounds):
|
||||
return
|
||||
|
||||
# 交换z_index
|
||||
for other in self.project.foregrounds:
|
||||
if other.z_index == new_idx:
|
||||
other.z_index = current_idx
|
||||
break
|
||||
|
||||
fg.z_index = new_idx
|
||||
self.canvas.draw_preview()
|
||||
|
||||
def reset_zoom(self):
|
||||
"""重置缩放"""
|
||||
self.project.canvas_zoom = 1.0
|
||||
self.canvas.zoom = 1.0
|
||||
self.canvas.draw_preview()
|
||||
|
||||
def new_project(self):
|
||||
"""新建项目"""
|
||||
if messagebox.askyesno("确认", "确定要创建新项目吗?当前项目将被清空。"):
|
||||
self.project = Project()
|
||||
self.canvas.project = self.project
|
||||
self.update_image_list()
|
||||
self.clear_property_panel()
|
||||
self.canvas.selected_item = None
|
||||
self.canvas.draw_preview()
|
||||
|
||||
def open_project(self):
|
||||
"""打开项目"""
|
||||
file_path = filedialog.askopenfilename(
|
||||
filetypes=[("项目文件", "*.json"), ("所有文件", "*.*")]
|
||||
)
|
||||
|
||||
if file_path:
|
||||
new_project = Project()
|
||||
if new_project.load(file_path):
|
||||
self.project = new_project
|
||||
self.canvas.project = self.project
|
||||
self.canvas.zoom = self.project.canvas_zoom
|
||||
self.update_image_list()
|
||||
self.clear_property_panel()
|
||||
self.canvas.selected_item = None
|
||||
self.canvas.draw_preview()
|
||||
else:
|
||||
messagebox.showerror("错误", "打开项目失败")
|
||||
|
||||
def save_project(self):
|
||||
"""保存项目"""
|
||||
if self.project.file_path:
|
||||
if self.project.save():
|
||||
messagebox.showinfo("成功", "项目保存成功")
|
||||
else:
|
||||
messagebox.showerror("错误", "项目保存失败")
|
||||
else:
|
||||
self.save_project_as()
|
||||
|
||||
def save_project_as(self):
|
||||
"""另存为项目"""
|
||||
file_path = filedialog.asksaveasfilename(
|
||||
defaultextension=".json",
|
||||
filetypes=[("项目文件", "*.json"), ("所有文件", "*.*")]
|
||||
)
|
||||
|
||||
if file_path:
|
||||
if self.project.save(file_path):
|
||||
messagebox.showinfo("成功", "项目保存成功")
|
||||
else:
|
||||
messagebox.showerror("错误", "项目保存失败")
|
||||
|
||||
def show_about(self):
|
||||
"""显示关于对话框"""
|
||||
messagebox.showinfo(
|
||||
"关于",
|
||||
"批量图片布局工具\n"
|
||||
"版本: 1.0\n"
|
||||
"用于批量处理图片布局的工具,可以将多张图片按照相同的布局应用到指定模板中。"
|
||||
)
|
264
models.py
Normal file
264
models.py
Normal file
@ -0,0 +1,264 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
import math
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class ForegroundImage:
|
||||
"""前景图对象,存储图片信息和变换参数"""
|
||||
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
# 四个顶点坐标 (左上, 右上, 左下, 右下)
|
||||
self.vertices = [
|
||||
(0.0, 0.0), # top-left
|
||||
(100.0, 0.0), # top-right
|
||||
(0.0, 100.0), # bottom-left
|
||||
(100.0, 100.0) # bottom-right
|
||||
]
|
||||
self.angle = 0.0 # 旋转角度
|
||||
self.mask_shape = "rect" # 遗罩形状
|
||||
self.locked = False # 是否锁定
|
||||
self.visible = True # 是否可见
|
||||
self.z_index = 0 # 图层顺序
|
||||
self.hidden = False # 是否隐藏(用于批量处理模式)
|
||||
|
||||
# 添加属性x, y, w, h的getter和setter方法,使其与vertices保持同步
|
||||
@property
|
||||
def x(self):
|
||||
return self.vertices[0][0] # 左上角x坐标作为x属性
|
||||
|
||||
@x.setter
|
||||
def x(self, value):
|
||||
# 更新所有顶点的x坐标以保持宽度不变
|
||||
dx = value - self.vertices[0][0]
|
||||
for i in range(len(self.vertices)):
|
||||
x, y = self.vertices[i]
|
||||
self.vertices[i] = (x + dx, y)
|
||||
|
||||
@property
|
||||
def y(self):
|
||||
return self.vertices[0][1] # 左上角y坐标作为y属性
|
||||
|
||||
@y.setter
|
||||
def y(self, value):
|
||||
# 更新所有顶点的y坐标以保持高度不变
|
||||
dy = value - self.vertices[0][1]
|
||||
for i in range(len(self.vertices)):
|
||||
x, y = self.vertices[i]
|
||||
self.vertices[i] = (x, y + dy)
|
||||
|
||||
@property
|
||||
def w(self):
|
||||
# 宽度为右上角和左上角x坐标的差值
|
||||
return self.vertices[1][0] - self.vertices[0][0]
|
||||
|
||||
@w.setter
|
||||
def w(self, value):
|
||||
# 保持左上角不变,调整右上角和右下角的x坐标
|
||||
if value > 0:
|
||||
old_w = self.vertices[1][0] - self.vertices[0][0]
|
||||
if old_w != 0:
|
||||
x0, y0 = self.vertices[0] # 左上角
|
||||
x1, y1 = self.vertices[1] # 右上角
|
||||
x2, y2 = self.vertices[2] # 左下角
|
||||
x3, y3 = self.vertices[3] # 右下角
|
||||
|
||||
# 调整右上角和右下角的x坐标
|
||||
self.vertices[1] = (x0 + value, y1) # 右上角
|
||||
self.vertices[3] = (x0 + value, y3) # 右下角
|
||||
|
||||
@property
|
||||
def h(self):
|
||||
# 高度为左下角和左上角y坐标的差值
|
||||
return self.vertices[2][1] - self.vertices[0][1]
|
||||
|
||||
@h.setter
|
||||
def h(self, value):
|
||||
# 保持左上角不变,调整左下角和右下角的y坐标
|
||||
if value > 0:
|
||||
old_h = self.vertices[2][1] - self.vertices[0][1]
|
||||
if old_h != 0:
|
||||
x0, y0 = self.vertices[0] # 左上角
|
||||
x1, y1 = self.vertices[1] # 右上角
|
||||
x2, y2 = self.vertices[2] # 左下角
|
||||
x3, y3 = self.vertices[3] # 右下角
|
||||
|
||||
# 调整左下角和右下角的y坐标
|
||||
self.vertices[2] = (x2, y0 + value) # 左下角
|
||||
self.vertices[3] = (x3, y0 + value) # 右下角
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典,用于序列化"""
|
||||
return {
|
||||
"file_path": self.file_path,
|
||||
"vertices": self.vertices,
|
||||
"angle": self.angle,
|
||||
"mask_shape": self.mask_shape,
|
||||
"locked": self.locked,
|
||||
"visible": self.visible,
|
||||
"z_index": self.z_index,
|
||||
"hidden": self.hidden
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
"""从字典创建对象"""
|
||||
fg = cls(data["file_path"])
|
||||
fg.vertices = data["vertices"]
|
||||
fg.angle = data["angle"]
|
||||
fg.mask_shape = data.get("mask_shape", "rect")
|
||||
fg.locked = data.get("locked", False)
|
||||
fg.visible = data.get("visible", True)
|
||||
fg.z_index = data.get("z_index", 0)
|
||||
fg.hidden = data.get("hidden", False)
|
||||
return fg
|
||||
|
||||
|
||||
class Template:
|
||||
"""模板对象,存储尺寸和背景信息"""
|
||||
|
||||
def __init__(self, ratio=(16, 9), width_px=1200):
|
||||
self.ratio = ratio
|
||||
self.width_px = width_px
|
||||
self.height_px = int(width_px * ratio[1] / ratio[0])
|
||||
self.bg_image = None
|
||||
self.bg_color = "#FFFFFF" # 默认白色背景
|
||||
|
||||
def set_custom_size(self, width, height):
|
||||
"""设置自定义尺寸"""
|
||||
self.width_px = width
|
||||
self.height_px = height
|
||||
# 根据实际尺寸设置比例
|
||||
gcd = math.gcd(width, height)
|
||||
self.ratio = (width // gcd, height // gcd)
|
||||
|
||||
def load_background(self, file_path):
|
||||
"""加载背景图"""
|
||||
try:
|
||||
self.bg_image = Image.open(file_path).convert("RGBA")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"加载背景图失败: {e}")
|
||||
return False
|
||||
|
||||
def clear_background(self):
|
||||
"""清除背景图"""
|
||||
self.bg_image = None
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典,用于序列化"""
|
||||
return {
|
||||
"ratio": self.ratio,
|
||||
"width_px": self.width_px,
|
||||
"height_px": self.height_px,
|
||||
"bg_color": self.bg_color
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
"""从字典创建对象"""
|
||||
template = cls(data["ratio"], data["width_px"])
|
||||
template.height_px = data["height_px"]
|
||||
template.bg_color = data.get("bg_color", "#FFFFFF")
|
||||
return template
|
||||
|
||||
|
||||
class Project:
|
||||
"""项目对象,管理模板和前景图"""
|
||||
|
||||
def __init__(self):
|
||||
self.template = Template()
|
||||
self.foregrounds = []
|
||||
self.canvas_zoom = 1.0
|
||||
self.file_path = None # 项目文件路径
|
||||
|
||||
def add_foreground(self, file_path, reference_fg=None):
|
||||
"""添加前景图"""
|
||||
fg = ForegroundImage(file_path)
|
||||
|
||||
# 如果提供了参考图片,则复制其属性
|
||||
if reference_fg is not None:
|
||||
fg.vertices = [tuple(v) for v in reference_fg.vertices] # 复制顶点
|
||||
fg.angle = reference_fg.angle
|
||||
fg.mask_shape = reference_fg.mask_shape
|
||||
fg.locked = reference_fg.locked
|
||||
fg.visible = reference_fg.visible
|
||||
else:
|
||||
# 尝试获取图片原始尺寸并设置初始大小
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
w, h = img.size
|
||||
# 按比例缩小到模板的2/3大小
|
||||
scale = min(self.template.width_px * 2/3 / w, self.template.height_px * 2/3 / h)
|
||||
# 计算初始位置(居中)
|
||||
initial_w = w * scale
|
||||
initial_h = h * scale
|
||||
initial_x = (self.template.width_px - initial_w) / 2
|
||||
initial_y = (self.template.height_px - initial_h) / 2
|
||||
|
||||
# 设置顶点坐标
|
||||
fg.vertices = [
|
||||
(initial_x, initial_y), # top-left
|
||||
(initial_x + initial_w, initial_y), # top-right
|
||||
(initial_x, initial_y + initial_h), # bottom-left
|
||||
(initial_x + initial_w, initial_y + initial_h) # bottom-right
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"获取图片尺寸失败: {e}")
|
||||
|
||||
# 设置图层顺序
|
||||
fg.z_index = len(self.foregrounds)
|
||||
self.foregrounds.append(fg)
|
||||
return fg
|
||||
|
||||
def remove_foreground(self, fg):
|
||||
"""移除前景图"""
|
||||
if fg in self.foregrounds:
|
||||
self.foregrounds.remove(fg)
|
||||
# 更新图层顺序
|
||||
self.update_z_indices()
|
||||
|
||||
def update_z_indices(self):
|
||||
"""更新所有前景图的图层顺序"""
|
||||
for i, fg in enumerate(sorted(self.foregrounds, key=lambda x: x.z_index)):
|
||||
fg.z_index = i
|
||||
|
||||
def save(self, file_path=None):
|
||||
"""保存项目到文件"""
|
||||
if file_path is None:
|
||||
file_path = self.file_path
|
||||
|
||||
if file_path is None:
|
||||
return False
|
||||
|
||||
data = {
|
||||
"template": self.template.to_dict(),
|
||||
"foregrounds": [fg.to_dict() for fg in self.foregrounds],
|
||||
"canvas_zoom": self.canvas_zoom
|
||||
}
|
||||
|
||||
try:
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
self.file_path = file_path
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"保存项目失败: {e}")
|
||||
return False
|
||||
|
||||
def load(self, file_path):
|
||||
"""从文件加载项目"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.template = Template.from_dict(data["template"])
|
||||
self.foregrounds = [ForegroundImage.from_dict(fg_data) for fg_data in data["foregrounds"]]
|
||||
self.canvas_zoom = data.get("canvas_zoom", 1.0)
|
||||
self.file_path = file_path
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"加载项目失败: {e}")
|
||||
return False
|
254
template_dialog.py
Normal file
254
template_dialog.py
Normal file
@ -0,0 +1,254 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from template_presets import TemplatePresetManager
|
||||
|
||||
|
||||
class TemplateDialog(tk.Toplevel):
|
||||
"""模板选择对话框"""
|
||||
|
||||
def __init__(self, parent, current_template):
|
||||
super().__init__(parent)
|
||||
self.title("模板设置")
|
||||
self.parent = parent
|
||||
self.current_template = current_template
|
||||
self.result = None
|
||||
|
||||
# 初始化预设管理器
|
||||
self.preset_manager = TemplatePresetManager()
|
||||
|
||||
# 确保对话框模态
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
|
||||
# 设置布局
|
||||
frame = ttk.Frame(self, padding=10)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# 预设管理
|
||||
preset_frame = ttk.LabelFrame(frame, text="模板预设", padding=5)
|
||||
preset_frame.grid(row=0, column=0, columnspan=3, sticky=tk.EW, pady=(0, 10))
|
||||
|
||||
ttk.Label(preset_frame, text="选择预设:").grid(row=0, column=0, sticky=tk.W)
|
||||
self.preset_var = tk.StringVar()
|
||||
self.preset_combo = ttk.Combobox(preset_frame, textvariable=self.preset_var, width=30)
|
||||
self.preset_combo.grid(row=0, column=1, padx=5)
|
||||
self.update_preset_list()
|
||||
|
||||
ttk.Button(preset_frame, text="加载", command=self.load_selected_preset).grid(row=0, column=2, padx=5)
|
||||
ttk.Button(preset_frame, text="保存当前为预设", command=self.save_current_as_preset).grid(row=0, column=3, padx=5)
|
||||
ttk.Button(preset_frame, text="删除选中预设", command=self.delete_selected_preset).grid(row=0, column=4, padx=5)
|
||||
|
||||
# 比例选择
|
||||
ttk.Label(frame, text="选择比例:").grid(row=1, column=0, sticky=tk.W, pady=5)
|
||||
self.ratio_var = tk.StringVar(value=f"{current_template.ratio[0]}:{current_template.ratio[1]}")
|
||||
ratio_frame = ttk.Frame(frame)
|
||||
ratio_frame.grid(row=1, column=1, sticky=tk.W, pady=5)
|
||||
|
||||
ratios = ["16:9", "3:4", "9:16", "4:3", "自定义"]
|
||||
for i, ratio in enumerate(ratios):
|
||||
ttk.Radiobutton(
|
||||
ratio_frame,
|
||||
text=ratio,
|
||||
variable=self.ratio_var,
|
||||
value=ratio,
|
||||
command=self.on_ratio_change
|
||||
).grid(row=0, column=i, padx=5)
|
||||
|
||||
# 自定义尺寸(默认隐藏)
|
||||
self.custom_frame = ttk.Frame(frame)
|
||||
ttk.Label(self.custom_frame, text="宽度:").grid(row=0, column=0, sticky=tk.W)
|
||||
self.width_var = tk.IntVar(value=current_template.width_px)
|
||||
ttk.Entry(self.custom_frame, textvariable=self.width_var, width=8).grid(row=0, column=1, padx=5)
|
||||
|
||||
ttk.Label(self.custom_frame, text="高度:").grid(row=0, column=2, sticky=tk.W, padx=5)
|
||||
self.height_var = tk.IntVar(value=current_template.height_px)
|
||||
ttk.Entry(self.custom_frame, textvariable=self.height_var, width=8).grid(row=0, column=3, padx=5)
|
||||
|
||||
# 背景设置
|
||||
ttk.Label(frame, text="背景颜色:").grid(row=3, column=0, sticky=tk.W, pady=5)
|
||||
self.bg_color_var = tk.StringVar(value=current_template.bg_color)
|
||||
ttk.Entry(frame, textvariable=self.bg_color_var, width=10).grid(row=3, column=1, sticky=tk.W, pady=5)
|
||||
|
||||
ttk.Label(frame, text="背景图:").grid(row=4, column=0, sticky=tk.W, pady=5)
|
||||
self.bg_image_var = tk.StringVar(value="无" if current_template.bg_image is None else "已设置")
|
||||
ttk.Label(frame, textvariable=self.bg_image_var).grid(row=4, column=1, sticky=tk.W, pady=5)
|
||||
|
||||
btn_frame = ttk.Frame(frame)
|
||||
ttk.Button(btn_frame, text="选择背景图", command=self.choose_bg_image).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(btn_frame, text="清除背景图", command=self.clear_bg_image).pack(side=tk.LEFT, padx=5)
|
||||
btn_frame.grid(row=4, column=2, sticky=tk.W, pady=5)
|
||||
|
||||
# 确认和取消按钮
|
||||
btn_frame = ttk.Frame(frame)
|
||||
ttk.Button(btn_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(btn_frame, text="取消", command=self.on_cancel).pack(side=tk.LEFT, padx=5)
|
||||
btn_frame.grid(row=5, column=0, columnspan=3, pady=10)
|
||||
|
||||
# 检查当前是否为自定义比例
|
||||
if f"{current_template.ratio[0]}:{current_template.ratio[1]}" not in ratios:
|
||||
self.ratio_var.set("自定义")
|
||||
self.show_custom_frame()
|
||||
else:
|
||||
self.hide_custom_frame()
|
||||
|
||||
# 调整大小
|
||||
self.geometry("700x300")
|
||||
self.wait_window(self)
|
||||
|
||||
def update_preset_list(self):
|
||||
"""更新预设列表"""
|
||||
presets = self.preset_manager.get_presets()
|
||||
preset_names = [preset["name"] for preset in presets["presets"]]
|
||||
self.preset_combo['values'] = preset_names
|
||||
if preset_names:
|
||||
self.preset_combo.current(0)
|
||||
|
||||
def load_selected_preset(self):
|
||||
"""加载选中的预设"""
|
||||
selected_preset = self.preset_var.get()
|
||||
if not selected_preset:
|
||||
messagebox.showwarning("警告", "请先选择一个预设")
|
||||
return
|
||||
|
||||
if self.preset_manager.load_preset_to_template(selected_preset, self.current_template):
|
||||
# 更新界面显示
|
||||
self.refresh_from_template()
|
||||
# 刷新主窗口画布
|
||||
self.parent.get_canvas().draw_preview()
|
||||
messagebox.showinfo("成功", f"已加载预设: {selected_preset}")
|
||||
else:
|
||||
messagebox.showerror("错误", "加载预设失败")
|
||||
|
||||
def save_current_as_preset(self):
|
||||
"""将当前设置保存为预设"""
|
||||
# 弹出输入框获取预设名称
|
||||
preset_name = tk.simpledialog.askstring("保存预设", "请输入预设名称:")
|
||||
if not preset_name:
|
||||
return
|
||||
|
||||
# 保存当前模板为预设
|
||||
if self.preset_manager.save_current_template_as_preset(self.current_template, preset_name):
|
||||
# 更新预设列表
|
||||
self.update_preset_list()
|
||||
messagebox.showinfo("成功", f"预设已保存: {preset_name}")
|
||||
else:
|
||||
messagebox.showerror("错误", "保存预设失败")
|
||||
|
||||
def delete_selected_preset(self):
|
||||
"""删除选中的预设"""
|
||||
selected_preset = self.preset_var.get()
|
||||
if not selected_preset:
|
||||
messagebox.showwarning("警告", "请先选择一个预设")
|
||||
return
|
||||
|
||||
if messagebox.askyesno("确认", f"确定要删除预设 '{selected_preset}' 吗?"):
|
||||
if self.preset_manager.remove_preset(selected_preset):
|
||||
self.update_preset_list()
|
||||
messagebox.showinfo("成功", f"预设已删除: {selected_preset}")
|
||||
else:
|
||||
messagebox.showerror("错误", "删除预设失败")
|
||||
|
||||
def refresh_from_template(self):
|
||||
"""根据当前模板刷新界面"""
|
||||
# 更新比例选择
|
||||
ratio_str = f"{self.current_template.ratio[0]}:{self.current_template.ratio[1]}"
|
||||
self.ratio_var.set(ratio_str)
|
||||
|
||||
# 更新尺寸
|
||||
self.width_var.set(self.current_template.width_px)
|
||||
self.height_var.set(self.current_template.height_px)
|
||||
|
||||
# 更新背景颜色
|
||||
self.bg_color_var.set(self.current_template.bg_color)
|
||||
|
||||
# 更新背景图状态
|
||||
self.bg_image_var.set("无" if self.current_template.bg_image is None else "已设置")
|
||||
|
||||
# 处理自定义比例显示
|
||||
ratios = ["16:9", "3:4", "9:16", "4:3"]
|
||||
if ratio_str in ratios:
|
||||
self.hide_custom_frame()
|
||||
else:
|
||||
self.show_custom_frame()
|
||||
|
||||
def on_ratio_change(self):
|
||||
"""处理比例变化"""
|
||||
if self.ratio_var.get() == "自定义":
|
||||
self.show_custom_frame()
|
||||
else:
|
||||
self.hide_custom_frame()
|
||||
# 设置默认尺寸
|
||||
w, h = map(int, self.ratio_var.get().split(":"))
|
||||
self.width_var.set(1920 if w > h else 1080)
|
||||
self.height_var.set(int(self.width_var.get() * h / w))
|
||||
|
||||
def show_custom_frame(self):
|
||||
"""显示自定义尺寸框"""
|
||||
self.custom_frame.grid(row=2, column=0, columnspan=3, sticky=tk.W, pady=5)
|
||||
|
||||
def hide_custom_frame(self):
|
||||
"""隐藏自定义尺寸框"""
|
||||
self.custom_frame.grid_forget()
|
||||
|
||||
def choose_bg_image(self):
|
||||
"""选择背景图"""
|
||||
file_path = filedialog.askopenfilename(
|
||||
filetypes=[
|
||||
("图像文件", "*.jpg;*.jpeg;*.png;*.webp;*.bmp"),
|
||||
("所有文件", "*.*")
|
||||
]
|
||||
)
|
||||
if file_path:
|
||||
if self.current_template.load_background(file_path):
|
||||
self.bg_image_var.set("已设置")
|
||||
|
||||
def clear_bg_image(self):
|
||||
"""清除背景图"""
|
||||
self.current_template.clear_background()
|
||||
self.bg_image_var.set("无")
|
||||
|
||||
def on_ok(self):
|
||||
"""确认按钮"""
|
||||
# 如果选择了预设,则应用预设
|
||||
selected_preset = self.preset_var.get()
|
||||
if selected_preset:
|
||||
if not self.preset_manager.load_preset_to_template(selected_preset, self.current_template):
|
||||
messagebox.showerror("错误", "加载预设失败")
|
||||
return
|
||||
|
||||
try:
|
||||
if self.ratio_var.get() == "自定义":
|
||||
width = self.width_var.get()
|
||||
height = self.height_var.get()
|
||||
if width <= 0 or height <= 0:
|
||||
messagebox.showerror("错误", "宽度和高度必须为正数")
|
||||
return
|
||||
self.result = {
|
||||
"type": "custom",
|
||||
"width": width,
|
||||
"height": height,
|
||||
"bg_color": self.bg_color_var.get()
|
||||
}
|
||||
else:
|
||||
w, h = map(int, self.ratio_var.get().split(":"))
|
||||
self.result = {
|
||||
"type": "preset",
|
||||
"ratio": (w, h),
|
||||
"width": self.width_var.get(),
|
||||
"bg_color": self.bg_color_var.get()
|
||||
}
|
||||
self.destroy()
|
||||
except Exception as e:
|
||||
messagebox.showerror("错误", f"设置模板失败: {str(e)}")
|
||||
|
||||
def on_cancel(self):
|
||||
"""取消按钮"""
|
||||
self.result = None
|
||||
self.destroy()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
111
transform_utils.py
Normal file
111
transform_utils.py
Normal file
@ -0,0 +1,111 @@
|
||||
import math
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
class ResizeHandle:
|
||||
"""图片缩放控制点"""
|
||||
|
||||
def __init__(self, position):
|
||||
# 位置: 'tl', 'tr', 'bl', 'br' 分别代表四个顶点
|
||||
self.position = position
|
||||
self.active = False
|
||||
self.x = 0
|
||||
self.y = 0
|
||||
self.radius = 5 # 控制点半径,从8减小到5
|
||||
|
||||
def hit_test(self, x, y):
|
||||
"""检查点是否在控制范围内"""
|
||||
dx = x - self.x
|
||||
dy = y - self.y
|
||||
return math.sqrt(dx * dx + dy * dy) <= self.radius
|
||||
|
||||
class TransformHelper:
|
||||
"""图片变形辅助工具"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_handles(fg, canvas):
|
||||
"""计算四个顶点的控制点位置"""
|
||||
handles = {
|
||||
'tl': ResizeHandle('tl'),
|
||||
'tr': ResizeHandle('tr'),
|
||||
'bl': ResizeHandle('bl'),
|
||||
'br': ResizeHandle('br')
|
||||
}
|
||||
|
||||
# 转换顶点坐标到画布坐标
|
||||
for i, pos in enumerate(['tl', 'tr', 'bl', 'br']):
|
||||
x, y = fg.vertices[i]
|
||||
canvas_x, canvas_y = canvas.template_to_canvas(x, y)
|
||||
handles[pos].x = canvas_x
|
||||
handles[pos].y = canvas_y
|
||||
|
||||
return handles
|
||||
|
||||
@staticmethod
|
||||
def update_vertices(fg, handle_pos, dx, dy, canvas):
|
||||
"""根据拖动的控制点更新顶点坐标和图片尺寸"""
|
||||
t_dx, t_dy = canvas.canvas_to_template(dx, dy)
|
||||
idx_map = {'tl': 0, 'tr': 1, 'bl': 2, 'br': 3}
|
||||
idx = idx_map[handle_pos]
|
||||
|
||||
# 更新对应顶点
|
||||
x, y = fg.vertices[idx]
|
||||
new_x, new_y = x + t_dx, y + t_dy
|
||||
|
||||
# 获取其他顶点
|
||||
tl = fg.vertices[0] if idx != 0 else (new_x, new_y)
|
||||
tr = fg.vertices[1] if idx != 1 else (new_x, new_y)
|
||||
bl = fg.vertices[2] if idx != 2 else (new_x, new_y)
|
||||
br = fg.vertices[3] if idx != 3 else (new_x, new_y)
|
||||
|
||||
# 计算宽度和高度
|
||||
width = max(abs(tr[0] - tl[0]), abs(br[0] - bl[0]))
|
||||
height = max(abs(bl[1] - tl[1]), abs(br[1] - tr[1]))
|
||||
|
||||
# 确保最小尺寸
|
||||
if width < 10 or height < 10:
|
||||
# 如果太小,则不更新
|
||||
return fg
|
||||
|
||||
fg.vertices[idx] = (new_x, new_y)
|
||||
|
||||
return fg
|
||||
|
||||
@staticmethod
|
||||
def get_perspective_matrix(src_vertices, dst_size):
|
||||
"""计算透视变换矩阵"""
|
||||
# 源坐标:图片四个角
|
||||
src = np.float32([
|
||||
[0, 0],
|
||||
[dst_size[0], 0],
|
||||
[0, dst_size[1]],
|
||||
[dst_size[0], dst_size[1]]
|
||||
])
|
||||
|
||||
# 目标坐标:变形后的四个角
|
||||
dst = np.float32(src_vertices)
|
||||
|
||||
# 计算透视变换矩阵
|
||||
return cv2.getPerspectiveTransform(src, dst)
|
||||
@staticmethod
|
||||
def update_image_size(fg, handle_pos, dx, dy, canvas):
|
||||
"""根据拖动的控制点更新图片尺寸"""
|
||||
t_dx, t_dy = canvas.canvas_to_template(dx, dy)
|
||||
|
||||
if handle_pos in ['nw', 'sw', 'w']:
|
||||
fg.x += t_dx
|
||||
fg.w -= t_dx
|
||||
if handle_pos in ['ne', 'se', 'e']:
|
||||
fg.w += t_dx
|
||||
if handle_pos in ['nw', 'ne', 'n']:
|
||||
fg.y += t_dy
|
||||
fg.h -= t_dy
|
||||
if handle_pos in ['sw', 'se', 's']:
|
||||
fg.h += t_dy
|
||||
|
||||
# 确保尺寸为正值
|
||||
fg.w = max(10, fg.w)
|
||||
fg.h = max(10, fg.h)
|
||||
|
||||
return fg
|
Loading…
Reference in New Issue
Block a user