[Python] 纯文本查看 复制代码
#!/usr/bin/env python3
"""
图片批量加边框工具 - 图形界面版
支持批量处理图片,可调节边框宽度、颜色、圆角半径、阴影等,美观易用。
"""
import os
import sys
import threading
from pathlib import Path
from tkinter import *
from tkinter import filedialog, messagebox, ttk, colorchooser
try:
from PIL import Image, ImageDraw, ImageFilter, ImageColor, ImageOps
except ImportError:
messagebox.showerror("缺少依赖", "请先安装 Pillow 库:pip install Pillow")
sys.exit(1)
class BorderApp:
def __init__(self, root):
self.root = root
self.root.title("图片批量加边框工具")
self.root.geometry("620x780")
self.root.resizable(True, True)
# 变量定义
self.input_dir = StringVar()
self.output_dir = StringVar()
self.border_width = IntVar(value=30)
self.border_color_hex = StringVar(value="#F0F0F0")
self.corner_radius = IntVar(value=0)
self.shadow_enabled = BooleanVar(value=False)
self.shadow_offset_x = IntVar(value=8)
self.shadow_offset_y = IntVar(value=8)
self.shadow_blur = IntVar(value=12)
self.shadow_opacity = DoubleVar(value=0.4)
self.quality = IntVar(value=95)
self.recursive = BooleanVar(value=True)
self.overwrite = BooleanVar(value=False)
# 颜色预览
self.color_preview = None
self.create_widgets()
def create_widgets(self):
# 主框架滚动条
main_frame = Frame(self.root)
main_frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
canvas = Canvas(main_frame)
scrollbar = Scrollbar(main_frame, orient=VERTICAL, command=canvas.yview)
scrollable_frame = Frame(canvas)
scrollable_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side=LEFT, fill=BOTH, expand=True)
scrollbar.pack(side=RIGHT, fill=Y)
# 使用 scrollable_frame 作为内容容器
parent = scrollable_frame
# 1. 文件夹选择
group_files = LabelFrame(parent, text="文件夹设置", padx=5, pady=5)
group_files.pack(fill=X, padx=5, pady=5)
Label(group_files, text="输入目录:").grid(row=0, column=0, sticky=W, padx=5, pady=2)
Entry(group_files, textvariable=self.input_dir, width=45).grid(row=0, column=1, padx=5, pady=2)
Button(group_files, text="浏览", command=self.select_input_dir).grid(row=0, column=2, padx=5)
Label(group_files, text="输出目录:").grid(row=1, column=0, sticky=W, padx=5, pady=2)
Entry(group_files, textvariable=self.output_dir, width=45).grid(row=1, column=1, padx=5, pady=2)
Button(group_files, text="浏览", command=self.select_output_dir).grid(row=1, column=2, padx=5)
Checkbutton(group_files, text="递归处理子文件夹", variable=self.recursive).grid(row=2, column=0, columnspan=3, sticky=W, padx=5, pady=2)
Checkbutton(group_files, text="覆盖已存在的文件", variable=self.overwrite).grid(row=3, column=0, columnspan=3, sticky=W, padx=5, pady=2)
# 2. 边框设置
group_border = LabelFrame(parent, text="边框参数", padx=5, pady=5)
group_border.pack(fill=X, padx=5, pady=5)
Label(group_border, text="边框宽度 (像素):").grid(row=0, column=0, sticky=W, padx=5, pady=2)
scale_width = Scale(group_border, from_=0, to=200, orient=HORIZONTAL, variable=self.border_width, length=200)
scale_width.grid(row=0, column=1, sticky=W, padx=5)
Label(group_border, textvariable=self.border_width).grid(row=0, column=2, sticky=W)
Label(group_border, text="边框颜色:").grid(row=1, column=0, sticky=W, padx=5, pady=2)
self.color_preview = Label(group_border, text=" ", bg=self.border_color_hex.get(), relief="sunken", width=4)
self.color_preview.grid(row=1, column=1, sticky=W, padx=5)
Button(group_border, text="选择颜色", command=self.choose_color).grid(row=1, column=2, sticky=W, padx=5)
Label(group_border, text="圆角半径 (像素,0=直角):").grid(row=2, column=0, sticky=W, padx=5, pady=2)
scale_radius = Scale(group_border, from_=0, to=100, orient=HORIZONTAL, variable=self.corner_radius, length=200)
scale_radius.grid(row=2, column=1, sticky=W, padx=5)
Label(group_border, textvariable=self.corner_radius).grid(row=2, column=2, sticky=W)
# 3. 阴影效果
group_shadow = LabelFrame(parent, text="阴影效果(可选)", padx=5, pady=5)
group_shadow.pack(fill=X, padx=5, pady=5)
Checkbutton(group_shadow, text="启用阴影", variable=self.shadow_enabled, command=self.toggle_shadow).grid(row=0, column=0, columnspan=3, sticky=W, padx=5)
# 阴影详细参数(默认禁用状态)
self.shadow_frame = Frame(group_shadow)
self.shadow_frame.grid(row=1, column=0, columnspan=3, sticky=W+E, padx=5, pady=2)
Label(self.shadow_frame, text="偏移量 X:").grid(row=0, column=0, sticky=W, padx=5)
Entry(self.shadow_frame, textvariable=self.shadow_offset_x, width=6).grid(row=0, column=1, padx=5)
Label(self.shadow_frame, text="Y:").grid(row=0, column=2, sticky=W)
Entry(self.shadow_frame, textvariable=self.shadow_offset_y, width=6).grid(row=0, column=3, padx=5)
Label(self.shadow_frame, text="模糊半径:").grid(row=1, column=0, sticky=W, padx=5)
Entry(self.shadow_frame, textvariable=self.shadow_blur, width=6).grid(row=1, column=1, padx=5)
Label(self.shadow_frame, text="不透明度 (0-1):").grid(row=2, column=0, sticky=W, padx=5)
Entry(self.shadow_frame, textvariable=self.shadow_opacity, width=6).grid(row=2, column=1, padx=5)
self.toggle_shadow() # 初始状态设置
# 4. 输出质量
group_quality = LabelFrame(parent, text="输出质量", padx=5, pady=5)
group_quality.pack(fill=X, padx=5, pady=5)
Label(group_quality, text="JPEG 质量 (1-100):").grid(row=0, column=0, sticky=W, padx=5)
scale_quality = Scale(group_quality, from_=1, to=100, orient=HORIZONTAL, variable=self.quality, length=200)
scale_quality.grid(row=0, column=1, sticky=W, padx=5)
Label(group_quality, textvariable=self.quality).grid(row=0, column=2, sticky=W)
# 5. 开始按钮和进度
self.btn_start = Button(parent, text="开始批量处理", command=self.start_processing, bg="#4CAF50", fg="white", font=("Arial", 12, "bold"))
self.btn_start.pack(pady=10, fill=X)
self.progress = ttk.Progressbar(parent, orient=HORIZONTAL, mode='determinate')
self.progress.pack(fill=X, pady=5)
self.log_text = Text(parent, height=12, wrap=WORD)
self.log_text.pack(fill=BOTH, expand=True, pady=5)
scroll_log = Scrollbar(self.log_text)
scroll_log.pack(side=RIGHT, fill=Y)
self.log_text.config(yscrollcommand=scroll_log.set)
scroll_log.config(command=self.log_text.yview)
# 状态栏
self.status_var = StringVar()
self.status_var.set("就绪")
status_bar = Label(self.root, textvariable=self.status_var, bd=1, relief=SUNKEN, anchor=W)
status_bar.pack(side=BOTTOM, fill=X)
def toggle_shadow(self):
"""根据阴影启用状态启用/禁用阴影参数"""
state = NORMAL if self.shadow_enabled.get() else DISABLED
for child in self.shadow_frame.winfo_children():
if isinstance(child, (Entry, Scale)):
child.config(state=state)
def choose_color(self):
color_code = colorchooser.askcolor(title="选择边框颜色", color=self.border_color_hex.get())
if color_code:
hex_color = color_code[1]
self.border_color_hex.set(hex_color)
self.color_preview.config(bg=hex_color)
def select_input_dir(self):
dir_path = filedialog.askdirectory(title="选择包含图片的文件夹")
if dir_path:
self.input_dir.set(dir_path)
def select_output_dir(self):
dir_path = filedialog.askdirectory(title="选择输出文件夹")
if dir_path:
self.output_dir.set(dir_path)
def log(self, message):
self.log_text.insert(END, message + "\n")
self.log_text.see(END)
self.root.update_idletasks()
def start_processing(self):
# 验证输入
if not self.input_dir.get():
messagebox.showerror("错误", "请选择输入目录")
return
if not self.output_dir.get():
messagebox.showerror("错误", "请选择输出目录")
return
# 禁用开始按钮,防止重复点击
self.btn_start.config(state=DISABLED, text="处理中...")
self.log_text.delete(1.0, END)
self.progress['value'] = 0
self.status_var.set("正在扫描图片...")
self.root.update()
# 在后台线程中处理,避免界面卡死
thread = threading.Thread(target=self.process_images, daemon=True)
thread.start()
def process_images(self):
input_dir = Path(self.input_dir.get())
output_dir = Path(self.output_dir.get())
recursive = self.recursive.get()
overwrite = self.overwrite.get()
border_width = self.border_width.get()
border_color_hex = self.border_color_hex.get()
corner_radius = self.corner_radius.get()
shadow = self.shadow_enabled.get()
shadow_offset = (self.shadow_offset_x.get(), self.shadow_offset_y.get())
shadow_blur = self.shadow_blur.get()
shadow_opacity = self.shadow_opacity.get()
quality = self.quality.get()
# 支持的扩展名
extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp']
# 收集所有图片
if recursive:
images = []
for ext in extensions:
images.extend(input_dir.rglob(f'*{ext}'))
images.extend(input_dir.rglob(f'*{ext.upper()}'))
else:
images = []
for ext in extensions:
images.extend(input_dir.glob(f'*{ext}'))
images.extend(input_dir.glob(f'*{ext.upper()}'))
images = sorted(set(images))
total = len(images)
if total == 0:
self.root.after(0, lambda: messagebox.showinfo("提示", "未找到任何图片文件"))
self.root.after(0, self.reset_ui)
return
self.log(f"找到 {total} 张图片,开始处理...")
self.root.after(0, lambda: self.progress.config(maximum=total))
success = 0
skip = 0
fail = 0
for idx, img_path in enumerate(images, 1):
rel_path = img_path.relative_to(input_dir)
output_path = output_dir / rel_path
# 检查跳过
if not overwrite and output_path.exists():
self.log(f"[{idx}/{total}] 跳过(已存在): {rel_path}")
skip += 1
self.root.after(0, lambda v=idx: self.progress.config(value=v))
continue
self.log(f"[{idx}/{total}] 处理: {rel_path}")
self.root.after(0, lambda msg=f"正在处理: {rel_path.name}": self.status_var.set(msg))
# 处理图片
try:
with Image.open(img_path) as img:
# 自动旋转EXIF方向
try:
img = ImageOps.exif_transpose(img)
except:
pass
# 添加边框
result = self.add_border(
img,
border_width,
border_color_hex,
corner_radius,
shadow,
shadow_offset,
shadow_blur,
shadow_opacity
)
# 创建输出目录
output_path.parent.mkdir(parents=True, exist_ok=True)
# 保存
if img_path.suffix.lower() in ['.jpg', '.jpeg']:
if result.mode in ('RGBA', 'LA', 'P'):
result = result.convert('RGB')
result.save(output_path, 'JPEG', quality=quality, optimize=True)
elif img_path.suffix.lower() == '.png':
result.save(output_path, 'PNG', optimize=True)
else:
result.save(output_path, quality=quality)
success += 1
except Exception as e:
self.log(f" ❌ 失败: {str(e)}")
fail += 1
# 更新进度
self.root.after(0, lambda v=idx: self.progress.config(value=v))
# 完成
self.log("\n" + "="*50)
self.log(f"处理完成!")
self.log(f" 成功: {success} 张")
self.log(f" 跳过: {skip} 张")
self.log(f" 失败: {fail} 张")
self.log(f"输出目录: {output_dir.absolute()}")
self.root.after(0, lambda: self.status_var.set(f"完成 - 成功:{success} 失败:{fail}"))
self.root.after(0, self.reset_ui)
self.root.after(0, lambda: messagebox.showinfo("完成", f"批量处理完成!\n成功: {success}\n失败: {fail}\n跳过: {skip}"))
def reset_ui(self):
self.btn_start.config(state=NORMAL, text="开始批量处理")
self.progress['value'] = 0
def add_border(self, image, border_width, border_color_hex, corner_radius, shadow, shadow_offset, shadow_blur, shadow_opacity):
"""为图像添加边框,返回新图像"""
# 解析颜色
border_rgba = ImageColor.getrgb(border_color_hex)
if len(border_rgba) == 3:
border_rgba = border_rgba + (255,)
# 转换为RGBA
if image.mode != 'RGBA':
image = image.convert('RGBA')
# 圆角处理(原图)
if corner_radius > 0:
img_rounded = self.add_rounded_corners(image, corner_radius)
else:
img_rounded = image.copy()
# 计算新尺寸
new_w = img_rounded.width + 2 * border_width
new_h = img_rounded.height + 2 * border_width
# 创建边框背景
border_img = Image.new('RGBA', (new_w, new_h), border_rgba)
# 粘贴原图
border_img.paste(img_rounded, (border_width, border_width), img_rounded)
# 整体圆角(边框外缘)
if corner_radius > 0:
total_radius = min(corner_radius + border_width // 2, new_w//2, new_h//2)
border_img = self.add_rounded_corners(border_img, total_radius)
# 阴影
if shadow:
border_img = self.drop_shadow(border_img, shadow_offset, shadow_blur, shadow_opacity)
return border_img
def add_rounded_corners(self, image, radius):
"""添加圆角Alpha通道"""
mask = Image.new('L', image.size, 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle([(0, 0), image.size], radius=radius, fill=255)
result = image.copy()
result.putalpha(mask)
return result
def drop_shadow(self, image, offset, blur_radius, opacity):
"""添加投影"""
if image.mode != 'RGBA':
image = image.convert('RGBA')
# 获取alpha通道
alpha = image.split()[-1]
shadow_mask = alpha.point(lambda p: int(p * opacity))
# 创建阴影层
shadow = Image.new('RGBA', image.size, (0,0,0,0))
shadow.putalpha(shadow_mask)
if blur_radius > 0:
shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius))
# 计算画布大小
total_w = image.width + abs(offset[0]) + blur_radius*2
total_h = image.height + abs(offset[1]) + blur_radius*2
canvas = Image.new('RGBA', (total_w, total_h), (0,0,0,0))
shadow_x = max(offset[0], 0) + blur_radius
shadow_y = max(offset[1], 0) + blur_radius
canvas.paste(shadow, (shadow_x, shadow_y), shadow)
img_x = max(-offset[0], 0) + blur_radius
img_y = max(-offset[1], 0) + blur_radius
canvas.paste(image, (img_x, img_y), image)
return canvas
def main():
root = Tk()
app = BorderApp(root)
root.mainloop()
if __name__ == "__main__":
main()