查看: 122|回复: 3

Python Tkinter Pillow批量图片缩放工具:防拉伸去重防卡顿完整代码

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
日常办公、自媒体运营或前端开发中,经常需要批量修改图片分辨率。市面上的工具要么有水印,要么收费,要么操作繁琐。本文基于Python原生GUI库Tkinter和Pillow图像处理库,打造一款免费、无广告、全格式兼容的批量图片缩放工具,支持自定义分辨率、保持宽高比、文件去重、进度展示,零基础可直接运行。
  1. pip install pillow
复制代码

一、核心功能设计

1. 全格式批量处理
支持JPG、JPEG、PNG、BMP、GIF、TIFF、WEBP、ICO等几乎所有主流格式,一次性导入多张图片,批量完成缩放。

2. 智能宽高比适配
开启“保持原始宽高比”后,自动计算适配尺寸,杜绝图片拉伸变形。输入框仅允许数字输入,自动拦截非法字符。

3. 文件去重与防覆盖
导入时自动去重,避免重复处理同一张图片。输出时检测同名文件,通过后缀递增(_resized_1、_resized_2)防止覆盖。

4. 可视化交互与防卡顿
图片列表显示文件名、原始尺寸、格式。支持多选删除指定图片。底部实时进度提示,通过 update_idletasks() 刷新界面,解决大批量处理时的假死问题。

5. 全版本兼容
兼容新旧版本Pillow库,自动适配LANCZOS高清缩放算法(异常捕获实现)。

二、关键技术实现

1. 高清缩放算法兼容处理
Pillow 9.1.0以上版本将缩放常量迁移至Image.Resampling。通过try-except兼容旧版:
  1. try:
  2.     RESAMPLE_FILTER = Image.Resampling.LANCZOS
  3. except AttributeError:
  4.     RESAMPLE_FILTER = Image.LANCZOS
复制代码
LANCZOS算法缩放后边缘更平滑,无锯齿模糊。

2. 宽高比智能适配核心逻辑
对比原图与目标画布的比例,自动适配最优尺寸,避免拉伸:
  1. if self.keep_aspect_ratio.get():
  2.     o_ratio = ow / oh
  3.     t_ratio = target_w / target_h
  4.     if o_ratio > t_ratio:
  5.         nw, nh = target_w, int(target_w / o_ratio)
  6.     else:
  7.         nh, nw = target_h, int(target_h * o_ratio)
  8. else:
  9.     nw, nh = target_w, target_h
复制代码

3. 输入框严格校验
通过Tkinter校验机制,限制宽高输入框仅可输入数字:
  1. def bind_num_only(self):
  2.     def only_num(input_str):
  3.         return input_str.isdigit() or input_str == ""
  4.     validate_cmd = self.root.register(only_num)
  5.     self.width_entry.config(validate="key", validatecommand=(validate_cmd, "%P"))
  6.     self.height_entry.config(validate="key", validatecommand=(validate_cmd, "%P"))
复制代码

4. 文件防覆盖去重逻辑
自动检测输出目录同名文件,通过后缀递增实现不覆盖:
  1. cnt = 1
  2. while os.path.exists(save_path):
  3.     save_path = os.path.join(self.output_path, f"{base}_resized{cnt}{ext}")
  4.     cnt += 1
复制代码

5. 界面防卡顿优化
大批量处理时,每处理一张图片就更新进度文本并调用update_idletasks():
  1. self.progress_var.set(f"处理中 {idx+1}/{total}: {os.path.basename(img_path)}")
  2. self.root.update_idletasks()
复制代码

三、完整可运行代码
  1. import tkinter as tk
  2. from tkinter import ttk, filedialog, messagebox
  3. from PIL import Image
  4. import os
  5. try:
  6.     RESAMPLE_FILTER = Image.Resampling.LANCZOS
  7. except AttributeError:
  8.     RESAMPLE_FILTER = Image.LANCZOS
  9. class ImageResizerApp:
  10.     def __init__(self, root):
  11.         self.root = root
  12.         self.root.title("图片分辨率修改工具")
  13.         self.root.geometry("800x650")
  14.         self.root.configure(bg="#f0f0f0")
  15.         self.root.columnconfigure(0, weight=1)
  16.         self.root.rowconfigure(0, weight=1)
  17.         self.image_paths = []
  18.         self.target_width = tk.StringVar(value="800")
  19.         self.target_height = tk.StringVar(value="600")
  20.         self.keep_aspect_ratio = tk.BooleanVar(value=True)
  21.         self.output_path = ""
  22.         self.setup_ui()
  23.         self.bind_num_only()
  24.     def bind_num_only(self):
  25.         def only_num(input_str):
  26.             return input_str.isdigit() or input_str == ""
  27.         validate_cmd = self.root.register(only_num)
  28.         self.width_entry.config(validate="key", validatecommand=(validate_cmd, "%P"))
  29.         self.height_entry.config(validate="key", validatecommand=(validate_cmd, "%P"))
  30.     def setup_ui(self):
  31.         title_label = tk.Label(self.root, text="图片分辨率修改工具", font=("Arial", 18, "bold"), bg="#f0f0f0", fg="#333")
  32.         title_label.pack(pady=15)
  33.         main_frame = tk.Frame(self.root, bg="#f0f0f0")
  34.         main_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=10)
  35.         main_frame.columnconfigure(1, weight=1)
  36.         main_frame.rowconfigure(0, weight=1)
  37.         control_frame = tk.LabelFrame(main_frame, text="控制面板", font=("Arial", 12), bg="#f0f0f0", fg="#333", padx=10, pady=10)
  38.         control_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
  39.         resolution_frame = tk.LabelFrame(control_frame, text="分辨率设置", font=("Arial", 10), bg="#f0f0f0", fg="#333")
  40.         resolution_frame.pack(fill=tk.X, pady=(0, 15))
  41.         width_frame = tk.Frame(resolution_frame, bg="#f0f0f0")
  42.         width_frame.pack(fill=tk.X, pady=5)
  43.         tk.Label(width_frame, text="宽度:", font=("Arial", 10), bg="#f0f0f0").pack(side=tk.LEFT)
  44.         self.width_entry = tk.Entry(width_frame, textvariable=self.target_width, font=("Arial", 10), width=10)
  45.         self.width_entry.pack(side=tk.RIGHT)
  46.         height_frame = tk.Frame(resolution_frame, bg="#f0f0f0")
  47.         height_frame.pack(fill=tk.X, pady=5)
  48.         tk.Label(height_frame, text="高度:", font=("Arial", 10), bg="#f0f0f0").pack(side=tk.LEFT)
  49.         self.height_entry = tk.Entry(height_frame, textvariable=self.target_height, font=("Arial", 10), width=10)
  50.         self.height_entry.pack(side=tk.RIGHT)
  51.         aspect_check = tk.Checkbutton(resolution_frame, text="保持原始宽高比", variable=self.keep_aspect_ratio, font=("Arial", 10), bg="#f0f0f0")
  52.         aspect_check.pack(pady=5)
  53.         io_frame = tk.LabelFrame(control_frame, text="导入导出设置", font=("Arial", 10), bg="#f0f0f0", fg="#333")
  54.         io_frame.pack(fill=tk.X, pady=10)
  55.         tk.Label(io_frame, text="导入图片:", font=("Arial", 10), bg="#f0f0f0").pack(anchor=tk.W, pady=(0, 5))
  56.         self.import_path_var = tk.StringVar(value="未选择")
  57.         import_path_label = tk.Label(io_frame, textvariable=self.import_path_var, font=("Arial", 9), bg="#ffffff", relief=tk.SUNKEN, anchor=tk.W, wraplength=180)
  58.         import_path_label.pack(fill=tk.X, pady=(0, 5))
  59.         import_btn = tk.Button(io_frame, text="导入图片", command=self.import_images, bg="#4CAF50", fg="white", font=("Arial", 10, "bold"), relief=tk.FLAT, padx=10, pady=5)
  60.         import_btn.pack(fill=tk.X, pady=5)
  61.         tk.Label(io_frame, text="输出路径:", font=("Arial", 10), bg="#f0f0f0").pack(anchor=tk.W, pady=(0, 5))
  62.         self.output_path_var = tk.StringVar(value="未选择")
  63.         output_path_label = tk.Label(io_frame, textvariable=self.output_path_var, font=("Arial", 9), bg="#ffffff", relief=tk.SUNKEN, anchor=tk.W, wraplength=180)
  64.         output_path_label.pack(fill=tk.X, pady=(0, 5))
  65.         select_output_btn = tk.Button(io_frame, text="选择输出路径", command=self.select_output_path, bg="#9C27B0", fg="white", font=("Arial", 10, "bold"), relief=tk.FLAT, padx=10, pady=3)
  66.         select_output_btn.pack(fill=tk.X)
  67.         button_frame = tk.Frame(control_frame, bg="#f0f0f0")
  68.         button_frame.pack(fill=tk.X, pady=10)
  69.         start_btn = tk.Button(button_frame, text="开始处理", command=self.start_processing, bg="#2196F3", fg="white", font=("Arial", 10, "bold"), relief=tk.FLAT, padx=10, pady=5)
  70.         start_btn.pack(fill=tk.X, pady=5)
  71.         del_btn = tk.Button(button_frame, text="删除选中图片", command=self.delete_selected_img, bg="#ff6b6b", fg="white", font=("Arial", 10, "bold"), relief=tk.FLAT, padx=10, pady=5)
  72.         del_btn.pack(fill=tk.X, pady=5)
  73.         reset_btn = tk.Button(button_frame, text="重置全部", command=self.reset, bg="#FF9800", fg="white", font=("Arial", 10, "bold"), relief=tk.FLAT, padx=10, pady=5)
  74.         reset_btn.pack(fill=tk.X, pady=5)
  75.         exit_btn = tk.Button(button_frame, text="退出", command=self.root.quit, bg="#f44336", fg="white", font=("Arial", 10, "bold"), relief=tk.FLAT, padx=10, pady=5)
  76.         exit_btn.pack(fill=tk.X, pady=5)
  77.         list_frame = tk.LabelFrame(main_frame, text="图片列表(可多选删除)", font=("Arial", 12), bg="#f0f0f0", fg="#333", padx=10, pady=10)
  78.         list_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
  79.         list_frame.columnconfigure(0, weight=1)
  80.         list_frame.rowconfigure(0, weight=1)
  81.         columns = ('filename', 'original_size', 'format')
  82.         self.tree = ttk.Treeview(list_frame, columns=columns, show='headings', height=5)
  83.         self.tree.heading('filename', text='文件名')
  84.         self.tree.heading('original_size', text='原始尺寸')
  85.         self.tree.heading('format', text='格式')
  86.         self.tree.column('filename', width=200, stretch=True)
  87.         self.tree.column('original_size', width=100, stretch=False)
  88.         self.tree.column('format', width=80, stretch=False)
  89.         scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.tree.yview)
  90.         self.tree.configure(yscrollcommand=scrollbar.set)
  91.         self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  92.         scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
  93.         self.progress_var = tk.StringVar(value="就绪")
  94.         progress_label = tk.Label(self.root, textvariable=self.progress_var, bg="#f0f0f0", fg="#555")
  95.         progress_label.pack(pady=3)
  96.     def delete_selected_img(self):
  97.         selected = self.tree.selection()
  98.         if not selected:
  99.             messagebox.showinfo("提示", "请先在列表选中图片")
  100.             return
  101.         indices = [self.tree.index(item) for item in selected]
  102.         for i in sorted(indices, reverse=True):
  103.             del self.image_paths[i]
  104.         self.refresh_image_list()
  105.     def import_images(self):
  106.         supported_formats = [("所有图片", "*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.tiff;*.webp;*.ico"), ("JPG/JPEG", "*.jpg;*.jpeg"), ("PNG", "*.png"), ("BMP", "*.bmp"), ("GIF", "*.gif"), ("TIFF", "*.tiff"), ("WEBP", "*.webp"), ("全部文件", "*.*")]
  107.         filenames = filedialog.askopenfilenames(title="选择图片", filetypes=supported_formats)
  108.         if not filenames:
  109.             return
  110.         add_count = 0
  111.         for f in filenames:
  112.             if f not in self.image_paths:
  113.                 self.image_paths.append(f)
  114.                 add_count += 1
  115.         dirs = set(os.path.dirname(p) for p in filenames)
  116.         if len(dirs) == 1:
  117.             tip = f"目录:{os.path.basename(list(dirs)[0])} 新增{add_count}张,总计{len(self.image_paths)}张"
  118.         else:
  119.             tip = f"多目录导入,新增{add_count}张,总计{len(self.image_paths)}张"
  120.         self.import_path_var.set(tip)
  121.         self.refresh_image_list()
  122.     def refresh_image_list(self):
  123.         self.tree.delete(*self.tree.get_children())
  124.         for path in self.image_paths:
  125.             try:
  126.                 with Image.open(path) as img:
  127.                     size_str = f"{img.width}×{img.height}"
  128.                     fmt = img.format or "未知"
  129.                     name = os.path.basename(path)
  130.                     self.tree.insert("", tk.END, values=(name, size_str, fmt))
  131.             except Exception as e:
  132.                 print(f"读取失败 {path}: {e}")
  133.                 self.tree.insert("", tk.END, values=(os.path.basename(path), "损坏", "-"))
  134.     def start_processing(self):
  135.         if not self.image_paths:
  136.             messagebox.showwarning("警告", "请先导入图片!")
  137.             return
  138.         if not self.output_path:
  139.             self.output_path = filedialog.askdirectory(title="选择输出文件夹")
  140.             if not self.output_path:
  141.                 return
  142.             self.output_path_var.set(self.output_path)
  143.         try:
  144.             target_w = int(self.target_width.get())
  145.             target_h = int(self.target_height.get())
  146.         except ValueError:
  147.             messagebox.showerror("错误", "宽高必须填写有效数字!")
  148.             return
  149.         if target_w <= 0 or target_h <= 0:
  150.             messagebox.showerror("错误", "宽高必须大于0!")
  151.             return
  152.         success = 0
  153.         fail = 0
  154.         total = len(self.image_paths)
  155.         for idx, img_path in enumerate(self.image_paths):
  156.             self.progress_var.set(f"处理中 {idx + 1}/{total}: {os.path.basename(img_path)}")
  157.             self.root.update_idletasks()
  158.             try:
  159.                 with Image.open(img_path) as img:
  160.                     ow, oh = img.size
  161.                     if self.keep_aspect_ratio.get():
  162.                         o_ratio = ow / oh
  163.                         t_ratio = target_w / target_h
  164.                         if o_ratio > t_ratio:
  165.                             nw, nh = target_w, int(target_w / o_ratio)
  166.                         else:
  167.                             nh, nw = target_h, int(target_h * o_ratio)
  168.                     else:
  169.                         nw, nh = target_w, target_h
  170.                     resized_img = img.resize((nw, nh), RESAMPLE_FILTER)
  171.                     base = os.path.splitext(os.path.basename(img_path))[0]
  172.                     ext = os.path.splitext(img_path)[1]
  173.                     save_path = os.path.join(self.output_path, f"{base}{ext}")
  174.                     cnt = 1
  175.                     while os.path.exists(save_path):
  176.                         save_path = os.path.join(self.output_path, f"{base}_resized{cnt}{ext}")
  177.                         cnt += 1
  178.                     save_kwargs = {"optimize": True}
  179.                     if ext.lower() in (".jpg", ".jpeg"):
  180.                         save_kwargs["quality"] = 95
  181.                     if ext.lower() == ".png":
  182.                         save_kwargs["compress_level"] = 6
  183.                     resized_img.save(save_path, **save_kwargs)
  184.                     success += 1
  185.             except Exception as e:
  186.                 print(f"处理失败 {img_path}: {str(e)}")
  187.                 fail += 1
  188.         self.progress_var.set(f"处理完成!成功{success}张,失败{fail}张")
  189.         messagebox.showinfo("完成", f"成功:{success} 张\n失败:{fail} 张")
  190.     def reset(self):
  191.         self.image_paths.clear()
  192.         self.target_width.set("800")
  193.         self.target_height.set("600")
  194.         self.keep_aspect_ratio.set(True)
  195.         self.output_path = ""
  196.         self.import_path_var.set("未选择")
  197.         self.output_path_var.set("未选择")
  198.         self.progress_var.set("就绪")
  199.         self.refresh_image_list()
  200.     def select_output_path(self):
  201.         path = filedialog.askdirectory(title="选择输出文件夹")
  202.         if path:
  203.             self.output_path = path
  204.             self.output_path_var.set(path)
  205. if __name__ == "__main__":
  206.     root = tk.Tk()
  207.     app = ImageResizerApp(root)
  208.     root.mainloop()
复制代码

四、使用与优化说明

运行环境:Python 3.6+,安装Pillow即可。代码已适配Windows/Mac/Linux。

目前工具已具备:
- 自动去重:同一文件不会重复添加到列表。
- 防覆盖:输出同名文件自动添加_resized数字后缀。
- 格式智能保存:JPG高质量(95)、PNG压缩等级6、其他格式默认优化。
- 异常跳过:损坏图片不会中断批量流程,仅统计失败数量。

后续可拓展方向:
- 添加图片预览缩略图。
- 支持拖拽导入。
- 增加批量格式转换功能。
- 添加处理队列暂停/继续。
- 支持自定义输出文件名模板。

该工具完全开源免费,可直接复制代码保存为.py文件运行,适合日常办公、自媒体运营和前端开发的批量图片缩放需求。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python Tkinter Pillow批量图片缩放工具:防拉伸去重防卡顿完整代码

感谢楼主分享这么实用的工具!代码逻辑清晰,细节考虑得很周到——尤其是兼容旧版Pillow的缩放算法、宽高比智能适配、输入框数字校验和防卡顿处理,这些在实际使用中能省去很多麻烦。我之前用其他工具经常遇到拉伸或覆盖原图的问题,这个工具正好解决了痛点。准备下载试试,顺便问下,如果原始图片尺寸比目标尺寸小,保持宽高比后是会放大还是保持原尺寸?代码里好像默认都缩放到目标大小了,建议可以加个选项控制是否只缩小不放大。再次感谢!
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python Tkinter Pillow批量图片缩放工具:防拉伸去重防卡顿完整代码

非常棒的工具分享!代码结构清晰,功能很实用,特别是宽高比适配和文件去重这些细节处理得很到位。我之前也用Pillow写过类似的脚本,但界面一直没时间做,你这个直接拿来就能跑,省了很多功夫。有一点想请教:用 `update_idletasks()` 在大量图片处理时,如果图片数量特别多(比如几百张高分辨率原图),会不会还是会出现短暂的界面无响应?有没有考虑过用多线程或者 `after()` 来进一步改善体验?再次感谢分享!
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python Tkinter Pillow批量图片缩放工具:防拉伸去重防卡顿完整代码

楼主这个工具很实用!代码清晰,功能考虑得也周到,特别是防卡顿和防覆盖的细节处理,对批量处理图片很有帮助。我正好在工作中需要用到类似功能,准备跑一下试试。想请教一下,如果图片文件很多,会不会对内存占用比较大?有没有考虑过用生成器逐批读取来优化?
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-7-6 12:35 , Processed in 0.025637 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部