From 1145dbfe49a4388051638547bd4991514d8aa07c Mon Sep 17 00:00:00 2001 From: wsb1224 Date: Tue, 2 Sep 2025 16:49:39 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=AC=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 8 + .idea/PictureEdit.iml | 8 + .idea/inspectionProfiles/Project_Default.xml | 7 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + canvas_widget.py | 277 ++++++++++ export_dialog.py | 153 +++++ exporter.py | 104 ++++ image_manager.py | 223 ++++++++ main.py | 6 + main_window.py | 523 ++++++++++++++++++ models.py | 264 +++++++++ template_dialog.py | 254 +++++++++ transform_utils.py | 111 ++++ 16 files changed, 1965 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/PictureEdit.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 canvas_widget.py create mode 100644 export_dialog.py create mode 100644 exporter.py create mode 100644 image_manager.py create mode 100644 main.py create mode 100644 main_window.py create mode 100644 models.py create mode 100644 template_dialog.py create mode 100644 transform_utils.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/PictureEdit.iml b/.idea/PictureEdit.iml new file mode 100644 index 0000000..8437fe6 --- /dev/null +++ b/.idea/PictureEdit.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..9c69411 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..9de2865 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..030c8f3 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/canvas_widget.py b/canvas_widget.py new file mode 100644 index 0000000..308c6c2 --- /dev/null +++ b/canvas_widget.py @@ -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("", self.on_click) + self.bind("", self.on_drag) + self.bind("", self.on_release) + self.bind("", self.on_zoom) # Windows + self.bind("", self.on_zoom) # Linux + self.bind("", 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" + ) \ No newline at end of file diff --git a/export_dialog.py b/export_dialog.py new file mode 100644 index 0000000..abe8176 --- /dev/null +++ b/export_dialog.py @@ -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() \ No newline at end of file diff --git a/exporter.py b/exporter.py new file mode 100644 index 0000000..e9be104 --- /dev/null +++ b/exporter.py @@ -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 \ No newline at end of file diff --git a/image_manager.py b/image_manager.py new file mode 100644 index 0000000..4da2952 --- /dev/null +++ b/image_manager.py @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..a8cff3e --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +import tkinter as tk +from main_window import MainWindow + +if __name__ == "__main__": + app = MainWindow() + app.mainloop() \ No newline at end of file diff --git a/main_window.py b/main_window.py new file mode 100644 index 0000000..593e771 --- /dev/null +++ b/main_window.py @@ -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('<>', 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("", 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("", 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("", 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("", 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("", 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" + "用于批量处理图片布局的工具,可以将多张图片按照相同的布局应用到指定模板中。" + ) \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..16fe18b --- /dev/null +++ b/models.py @@ -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 diff --git a/template_dialog.py b/template_dialog.py new file mode 100644 index 0000000..a62e2c5 --- /dev/null +++ b/template_dialog.py @@ -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() + + + + + + + diff --git a/transform_utils.py b/transform_utils.py new file mode 100644 index 0000000..3e7253a --- /dev/null +++ b/transform_utils.py @@ -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 \ No newline at end of file