PictureEdit/main_window.py

555 lines
22 KiB
Python
Raw Normal View History

2025-09-04 12:55:33 +08:00
"""
作者太一
微信taiyi1224
邮箱shuobo1224@qq.com
"""
2025-09-02 16:49:39 +08:00
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__()
2025-09-04 17:09:57 +08:00
self.title("批量图片布局工具仅供交流使用作者taiyi1224")
2025-09-02 16:49:39 +08:00
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
2025-09-04 12:55:33 +08:00
2025-09-02 16:49:39 +08:00
# 添加所有选择的图片
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)
2025-09-04 12:55:33 +08:00
2025-09-02 16:49:39 +08:00
# 如果是第一次批量添加,则设置批量处理模式
if is_first_batch and len(added_foregrounds) > 1:
# 第一张图片保持可见和可编辑
first_fg = added_foregrounds[0]
first_fg.hidden = False
first_fg.visible = True
2025-09-04 12:55:33 +08:00
2025-09-02 16:49:39 +08:00
# 其他图片设置为隐藏
for fg in added_foregrounds[1:]:
fg.hidden = True
fg.visible = False
2025-09-04 12:55:33 +08:00
2025-09-02 16:49:39 +08:00
# 选中第一张图片
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:
2025-09-04 12:55:33 +08:00
# 处理所有类型的模板设置结果
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":
# 更新现有模板的属性
2025-09-02 16:49:39 +08:00
self.project.template.set_custom_size(
dialog.result["width"],
dialog.result["height"]
)
2025-09-04 12:55:33 +08:00
# 设置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"]
# 确保更新画布显示
2025-09-02 16:49:39 +08:00
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"
2025-09-04 12:55:33 +08:00
2025-09-02 16:49:39 +08:00
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"
2025-09-04 17:09:57 +08:00
"用于批量处理图片布局的工具,可以将多张图片按照相同的布局应用到指定模板中。\n"
"作者:太一\n"
"联系VXtaiyi1224"
2025-09-02 16:49:39 +08:00
)