PictureEdit/main_window.py
2025-09-04 12:58:13 +08:00

553 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
作者:太一
微信taiyi1224
邮箱shuobo1224@qq.com
"""
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_loaded":
# 直接更新现有模板的属性
self.project.template.ratio = dialog.result["ratio"]
self.project.template.width_px = dialog.result["width"]
self.project.template.height_px = dialog.result["height"]
self.project.template.bg_color = dialog.result["bg_color"]
if "bg_image_path" in dialog.result and dialog.result["bg_image_path"]:
self.project.template.load_background(dialog.result["bg_image_path"])
elif dialog.result["type"] == "preset":
# 更新现有模板的属性
self.project.template.ratio = tuple(dialog.result["ratio"])
self.project.template.width_px = dialog.result["width"]
self.project.template.height_px = dialog.result["height"]
self.project.template.bg_color = dialog.result["bg_color"]
# 设置背景图
if "bg_image_path" in dialog.result and dialog.result["bg_image_path"]:
self.project.template.load_background(dialog.result["bg_image_path"])
elif "bg_image" in dialog.result:
self.project.template.bg_image = dialog.result["bg_image"]
elif dialog.result["type"] == "custom":
# 更新现有模板的属性
self.project.template.set_custom_size(
dialog.result["width"],
dialog.result["height"]
)
# 设置ratio属性
if "ratio" in dialog.result:
self.project.template.ratio = dialog.result["ratio"]
self.project.template.bg_color = dialog.result["bg_color"]
# 设置背景图
if "bg_image_path" in dialog.result and dialog.result["bg_image_path"]:
self.project.template.load_background(dialog.result["bg_image_path"])
elif "bg_image" in dialog.result:
self.project.template.bg_image = dialog.result["bg_image"]
# 确保更新画布显示
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"
"用于批量处理图片布局的工具,可以将多张图片按照相同的布局应用到指定模板中。"
)