欢迎使用 Typecho
2024年11月26日 19:41
如果您看到这篇文章,表示您的 blog 已经安装成功.
import os
import threading
from PIL import Image
from io import BytesIO
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from tkinterdnd2 import DND_FILES, TkinterDnD
import platform
import subprocess
import traceback
# 支持的图片格式
SUPPORTED_FORMATS = ['JPEG', 'JPG', 'PNG']
def is_valid_path(path):
"""验证路径的合法性"""
directory = os.path.dirname(path)
if not directory:
directory = '.'
return os.path.isdir(directory) and os.access(directory, os.W_OK)
def open_directory(path):
"""跨平台打开文件夹"""
try:
if platform.system() == "Windows":
os.startfile(path)
elif platform.system() == "Darwin": # macOS
subprocess.Popen(["open", path])
else: # Linux and others
subprocess.Popen(["xdg-open", path])
except Exception as e:
messagebox.showerror("错误", f"无法打开文件夹: {e}")
def get_image_size(img: Image.Image, format: str, quality: int = None):
"""获取图片在指定格式和质量下的大小(KB)"""
buffer = BytesIO()
try:
save_kwargs = {}
if format.lower() in ['jpeg', 'jpg']:
save_kwargs['format'] = format
if quality is not None:
save_kwargs['quality'] = quality
elif format.lower() == 'png':
save_kwargs['format'] = format
save_kwargs['optimize'] = True
save_kwargs['compress_level'] = 9 # 最大压缩
img.save(buffer, **save_kwargs)
size_kb = buffer.tell() / 1024
except Exception as e:
buffer.close()
raise e
buffer.close()
return size_kb
def compress_image_thread(image_path: str, target_size_kb: int, gui, item_id):
"""
线程中执行的压缩函数
"""
try:
with Image.open(image_path) as img:
img_format = img.format.upper() # 保持原始格式(大写)
if img_format not in SUPPORTED_FORMATS:
gui.update_item_status(item_id, False, f"暂不支持 {img_format} 格式的图片。")
return
# 确定初始条件
if img_format in ['JPEG', 'JPG']:
quality = 95
step = 5
min_quality = 10
current_size = get_image_size(img, img_format, quality)
gui.update_item_progress(item_id, 10)
gui.update_item_status(item_id, True, "开始压缩...")
# 如果图片已经小于目标,无需压缩
if current_size <= target_size_kb:
gui.update_item_status(item_id, True, "图片已经小于或等于目标大小,无需压缩。")
gui.update_item_progress(item_id, 100)
gui.update_item_result(item_id, "无需压缩", image_path, current_size)
return
# 压缩循环 - 质量调整
while current_size > target_size_kb and quality > min_quality:
quality -= step
current_size = get_image_size(img, img_format, quality)
progress_val = 10 + ((95 - quality) / (95 - min_quality)) * 60 # 10-70%
gui.update_item_progress(item_id, progress_val)
# 如果质量压缩不足,尝试缩放图片尺寸
if current_size > target_size_kb:
gui.update_item_status(item_id, True, "进一步压缩通过调整图片尺寸。")
width, height = img.size
while current_size > target_size_kb and width > 100 and height > 100:
width = int(width * 0.9)
height = int(height * 0.9)
img_resized = img.resize((width, height), Image.Resampling.LANCZOS)
current_size = get_image_size(img_resized, img_format, quality)
progress_val = 70 + ((95 - quality) / (95 - min_quality)) * 20 # 70-90%
gui.update_item_progress(item_id, progress_val)
if current_size <= target_size_kb:
img = img_resized # 更新到缩放后的图片
break
else:
img = img_resized # 最后一次调整后的图片
elif img_format == 'PNG':
current_size = get_image_size(img, img_format)
gui.update_item_progress(item_id, 10)
gui.update_item_status(item_id, True, "开始压缩...")
# 如果图片已经小于目标,无需压缩
if current_size <= target_size_kb:
gui.update_item_status(item_id, True, "图片已经小于或等于目标大小,无需压缩。")
gui.update_item_progress(item_id, 100)
gui.update_item_result(item_id, "无需压缩", image_path, current_size)
return
# 尝试通过调整图片尺寸压缩
gui.update_item_status(item_id, True, "压缩通过调整图片尺寸。")
width, height = img.size
while current_size > target_size_kb and width > 100 and height > 100:
width = int(width * 0.9)
height = int(height * 0.9)
img_resized = img.resize((width, height), Image.Resampling.LANCZOS)
current_size = get_image_size(img_resized, img_format)
progress_val = 10 + (1 - (current_size / target_size_kb)) * 80 # 10-90%
gui.update_item_progress(item_id, min(progress_val, 90))
if current_size <= target_size_kb:
img = img_resized # 更新到缩放后的图片
break
else:
img = img_resized # 最后一次调整后的图片
# 确定输出路径
base, ext = os.path.splitext(image_path)
output_path = f"{base}_compressed{ext}"
# 检查路径合法性
if not is_valid_path(output_path):
gui.update_item_status(item_id, False, f"输出路径无效: {output_path}")
return
# 保存最终压缩的图片
try:
if img_format in ['JPEG', 'JPG']:
img.save(output_path, format=img_format, quality=quality)
else:
img.save(output_path, format=img_format, optimize=True, compress_level=9)
except Exception as e:
gui.update_item_status(item_id, False, f"保存压缩图片时出错: {e}")
return
# 获取最终大小
final_size = os.path.getsize(output_path) / 1024
gui.update_item_progress(item_id, 100)
gui.update_item_result(item_id, "压缩完成", output_path, final_size)
except Exception as e:
error_message = traceback.format_exc()
gui.update_item_status(item_id, False, f"压缩过程中出错:\n{error_message}")
class ImageCompressorGUI:
def __init__(self, root):
self.root = root
self.root.title("图片压缩工具")
self.root.geometry("900x600")
self.root.configure(bg="#f0f2f5")
self.root.resizable(True, True)
self.initialize_styles()
self.initialize_widgets()
# 初始化选定文件变量
self.selected_files = []
def initialize_styles(self):
"""初始化样式"""
self.style = ttk.Style()
self.style.theme_use('clam')
# 按钮样式
self.style.configure("TButton",
padding=6,
relief="flat",
background="#4CAF50",
foreground="white",
font=("Helvetica", 10, "bold"))
self.style.map("TButton",
background=[('active', '#45a049')])
# 标签样式
self.style.configure("TLabel",
background="#f0f2f5",
font=("Helvetica", 10))
self.style.configure("Header.TLabel",
background="#f0f2f5",
font=("Helvetica", 16, "bold"))
# 输入框样式
self.style.configure("TEntry",
padding=6,
font=("Helvetica", 10))
# 进度条样式
self.style.configure("Progressbar.Horizontal.TProgressbar",
troughcolor="#d3d3d3",
background="#4CAF50")
# Treeview样式
self.style.configure("Treeview",
background="#ffffff",
foreground="black",
rowheight=25,
fieldbackground="#ffffff",
font=("Helvetica", 10))
self.style.configure("Treeview.Heading",
background="#4CAF50",
foreground="white",
font=("Helvetica", 10, "bold"))
def initialize_widgets(self):
"""初始化界面组件"""
# Header
header = ttk.Label(self.root, text="图片压缩工具", style="Header.TLabel")
header.pack(pady=10)
# 选择图片区域(支持拖放)
self.frame_drop = ttk.Frame(self.root, borderwidth=2, relief="ridge", padding=10)
self.frame_drop.pack(pady=10, padx=20, fill='x', expand=False)
self.label_drop = ttk.Label(self.frame_drop,
text="将图片拖放到此处或点击浏览按钮选择",
foreground="#888",
font=("Helvetica", 12))
self.label_drop.pack(expand=True)
# 注册拖放目标
self.frame_drop.drop_target_register(DND_FILES)
self.frame_drop.dnd_bind('<<Drop>>', self.drop)
# 浏览按钮
self.button_browse = ttk.Button(self.root, text="浏览图片", command=self.browse_images)
self.button_browse.pack(pady=5)
# 输入目标大小
frame_size = ttk.Frame(self.root)
frame_size.pack(pady=10)
ttk.Label(frame_size, text="目标大小 (KB):", font=("Helvetica", 10)).pack(side=tk.LEFT, padx=(0, 5))
self.target_size = tk.StringVar(value="200") # 默认目标大小
self.entry_size = ttk.Entry(frame_size, width=20, textvariable=self.target_size)
self.entry_size.pack(side=tk.LEFT)
# 压缩按钮
self.button_compress = ttk.Button(self.root, text="开始压缩", command=self.start_compression)
self.button_compress.pack(pady=10)
# Treeview 显示文件列表
columns = ("filename", "status", "progress", "path", "size")
self.tree = ttk.Treeview(self.root, columns=columns, show='headings', selectmode='none')
self.tree.heading("filename", text="文件名")
self.tree.heading("status", text="状态")
self.tree.heading("progress", text="进度")
self.tree.heading("path", text="输出路径")
self.tree.heading("size", text="压缩后大小 (KB)")
self.tree.column("filename", width=200, anchor='center')
self.tree.column("status", width=100, anchor='center')
self.tree.column("progress", width=100, anchor='center')
self.tree.column("path", width=300, anchor='center')
self.tree.column("size", width=150, anchor='center')
self.tree.pack(pady=10, padx=20, fill='both', expand=True)
# 添加垂直滚动条
scrollbar = ttk.Scrollbar(self.tree, orient="vertical", command=self.tree.yview)
self.tree.configure(yscroll=scrollbar.set)
scrollbar.pack(side='right', fill='y')
# 状态栏
self.status_label = ttk.Label(self.root, text="准备压缩...", foreground="#333", font=("Helvetica", 10))
self.status_label.pack(pady=5)
def browse_images(self):
"""浏览并选择多张图片"""
filetypes = (
("Image files", "*.jpg *.jpeg *.png"),
("All files", "*.*")
)
filenames = filedialog.askopenfilenames(title="选择图片", initialdir=os.getcwd(), filetypes=filetypes)
if filenames:
self.add_files(filenames)
def drop(self, event):
"""处理拖放事件"""
files = self.root.splitlist(event.data)
valid_files = [f for f in files if os.path.isfile(f) and f.lower().endswith(('.jpg', '.jpeg', '.png'))]
if valid_files:
self.add_files(valid_files)
else:
messagebox.showerror("错误", "仅支持 .jpg, .jpeg, .png 格式的图片。")
def add_files(self, filenames):
"""将选中的文件添加到列表中"""
for file in filenames:
if file not in self.selected_files:
self.selected_files.append(file)
filename = os.path.basename(file)
self.tree.insert("", tk.END, values=(filename, "待压缩", "0%", "", ""))
self.label_drop.config(text=f"{len(self.selected_files)} 张图片已选择", foreground="#000")
def update_item_status(self, item_id, success, message):
"""更新单个文件的状态"""
status = "成功" if success else "失败"
self.tree.set(item_id, "status", message if not success else status)
def update_item_progress(self, item_id, progress):
"""更新单个文件的压缩进度"""
progress_text = f"{int(progress)}%"
self.tree.set(item_id, "progress", progress_text)
def update_item_result(self, item_id, message, output_path, size_kb):
"""更新单个文件的压缩结果"""
self.tree.set(item_id, "status", message)
self.tree.set(item_id, "path", output_path)
self.tree.set(item_id, "size", f"{size_kb:.2f}")
# 绑定双击事件打开文件夹
if message == "压缩完成":
self.tree.item(item_id, tags=("success",))
self.tree.tag_bind("success", "<Double-1>", self.open_file_folder)
def open_file_folder(self, event):
"""打开压缩后图片所在的文件夹"""
selected_item = self.tree.selection()
if selected_item:
item = selected_item[0]
output_path = self.tree.set(item, "path")
if output_path and os.path.exists(output_path):
open_directory(os.path.dirname(output_path))
else:
messagebox.showerror("错误", "找不到输出路径。")
def start_compression(self):
"""开始压缩所有选中的图片"""
if not self.selected_files:
messagebox.showwarning("警告", "请先选择至少一张图片。")
return
try:
target_size_kb = int(self.target_size.get())
if target_size_kb <= 0:
raise ValueError
except ValueError:
messagebox.showerror("错误", "请输入有效的目标大小 (KB)。")
return
# 禁用按钮以防多次点击
self.button_compress.config(state=tk.DISABLED)
self.button_browse.config(state=tk.DISABLED)
# 更新状态
self.update_status("开始压缩...")
# 启动压缩线程
threading.Thread(target=self.compress_all_images, args=(target_size_kb,), daemon=True).start()
def compress_all_images(self, target_size_kb):
"""压缩所有选中的图片"""
for idx, image_path in enumerate(self.selected_files):
# 获取 Treeview 项目ID
item_id = self.tree.get_children()[idx]
# 更新状态为压缩中
self.tree.set(item_id, "status", "压缩中...")
self.tree.set(item_id, "progress", "0%")
# 启动单个文件的压缩线程
threading.Thread(target=compress_image_thread, args=(image_path, target_size_kb, self, item_id), daemon=True).start()
# 压缩完成后恢复按钮
self.root.after(100, self.enable_buttons_after_compression)
def enable_buttons_after_compression(self):
"""压缩完成后重新启用按钮"""
self.button_compress.config(state=tk.NORMAL)
self.button_browse.config(state=tk.NORMAL)
self.update_status("压缩完成。")
def update_status(self, message):
"""更新状态栏消息"""
self.status_label.config(text=message)
def main():
root = TkinterDnD.Tk()
app = ImageCompressorGUI(root)
root.mainloop()
if __name__ == "__main__":
main()
支持 JPEG, JPG, PNG 格式的图片压缩。
用户可以通过输入框指定目标压缩大小(单位:KB)。
使用 TkinterDnD 实现了拖放功能,用户可以直接将图片拖到应用窗口中。
提供文件选择对话框,支持一次选择多张图片。
对于 JPEG/JPG 图片,优先通过调整图片质量进行压缩;如果质量调整不足,再尝试缩放图片尺寸。
对于 PNG 图片,直接通过调整尺寸进行压缩。
使用 threading 实现多线程处理,防止主线程阻塞,提升用户体验。
使用 Treeview 显示每张图片的压缩状态、进度、输出路径以及压缩后的大小。
跨平台文件夹打开:
支持 Windows、macOS 和 Linux 平台打开文件夹功能。
使用 ttk.Style 对按钮、进度条、Treeview 等组件进行了美化。