功能亮点
智能质量探测: 自动在设定的质量范围内寻找最佳压缩比,平衡文件大小和视觉质量。
透明通道自动识别: 自动检测图片是否包含透明通道,并选择最佳格式(PNG 用于透明图,JPEG 用于不透明图)进行压缩。
递归目录处理: 支持递归处理整个文件夹及其所有子文件夹中的图片。
详细压缩报告: 任务完成后,生成详细的统计报告,包括处理数量、节省空间、平均压缩率和总耗时。
多格式支持: 支持压缩最常见的网络图片格式:JPEG 和 PNG。
高度可定制: 可通过参数轻松调整压缩质量、覆盖选项等。
安装
克隆本仓库到本地:
安装依赖项 (Pillow):pip install -r requirements.txt
使用方法 方式一:直接修改脚本
将您需要压缩的图片或图片文件夹放入 input_images 目录(如果不存在,请手动创建)。
打开 compressor.py 文件 。
在文件底部的 if __name__ == "__main__": 部分,根据您的需求修改参数:
input_path = "input_images/"#输入路径(文件或目录)
output_dir = "output_images/"#输出目录路径
quality = 85 # 初始质量参数 (默认: 85)
max_reduction = 25 # 最大质量降幅 (默认: 25)
overwrite = False # 是否覆盖已存在文件 (默认: False)
recurse = True # 是否递归处理子目录 (默认: True)
运行脚本:python compressor.py
压缩后的图片将保留原始目录结构,并保存在 output_images 文件夹中。
方式二:使用命令行参数 (推荐)
压缩单个文件
python compressor.py "input_images/logo.png" -o "output_images/"
压缩整个目录(非递归)
python compressor.py "input_images/" -o "output_images/"
递归压缩整个目录,并设置初始质量为90
python compressor.py "input_images/" -o "output_images/" -r -q 90
查看帮助
python compressor.py -h

# /*
# * Copyright 2025 RealYasuHaru
# *
# * Licensed under the Apache License, Version 2.0 (the "License");
# * you may not use this file except in compliance with the License.
# * You may obtain a copy of the License at
# *
# * http://www.apache.org/licenses/LICENSE-2.0
# *
# * Unless required by applicable law or agreed to in writing, software
# * distributed under the License is distributed on an "AS IS" BASIS,
# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# * See the License for the specific language governing permissions and
# * limitations under the License.
# */
#!/usr/bin/env python3
import os
import time
import argparse
from pathlib import Path
from PIL import Image
class SmartImageCompressor:
def __init__(self):
self.stats = {
'total': 0,
'processed': 0,
'skipped': 0,
'failed': 0,
'original_size': 0,
'compressed_size': 0,
'start_time': 0
}
def optimize_image(self, input_path: Path, output_path: Path, quality: int, max_reduction: int, overwrite: bool) -> bool:
if not overwrite and output_path.exists():
self.stats['skipped'] += 1
print(f"跳过已存在文件: {output_path}")
return False
try:
with Image.open(input_path) as img:
original_size = input_path.stat().st_size
best_quality = quality
best_size = float('inf')
if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info):
img_format = 'PNG'
img = img.convert('RGBA')
else:
img_format = 'JPEG'
img = img.convert('RGB')
for q in range(quality, quality - max_reduction - 1, -5):
if q <= 0: continue
try:
save_args = {
'format': img_format,
'quality': q,
'optimize': True,
}
if img_format == 'JPEG':
save_args['progressive'] = True
elif img_format == 'PNG':
save_args['compress_level'] = 9
img.save(output_path, **save_args)
current_size = output_path.stat().st_size
if current_size < best_size:
best_size = current_size
best_quality = q
else:
break
except Exception as e:
print(f"质量 {q} 临时保存出错: {str(e)}")
break
final_save_args = {
'format': img_format, 'quality': best_quality, 'optimize': True
}
if img_format == 'JPEG': final_save_args['progressive'] = True
if img_format == 'PNG': final_save_args['compress_level'] = 9
img.save(output_path, **final_save_args)
compressed_size = output_path.stat().st_size
if original_size == 0:
ratio = 0
else:
ratio = (compressed_size / original_size) * 100
self.stats['original_size'] += original_size
self.stats['compressed_size'] += compressed_size
print(f" 压缩完成: {input_path.name}")
print(f" 尺寸: {original_size / 1024:.1f}KB → {compressed_size / 1024:.1f}KB")
print(f" 压缩率: {ratio:.1f}% | 最终质量: {best_quality}\n")
return True
except Exception as e:
self.stats['failed'] += 1
print(f"处理失败: {input_path}")
print(f" 错误: {str(e)}\n")
if output_path.exists():
os.remove(output_path)
return False
def batch_compress(self, input_path: str, output_dir: str, quality: int, max_reduction: int,
overwrite: bool, recurse: bool, pattern: str):
self.stats['start_time'] = time.time()
input_p = Path(input_path).resolve()
output_d = Path(output_dir).resolve()
if not input_p.exists():
print(f"输入路径不存在: {input_p}")
return
if input_p.is_file():
files = [input_p]
else:
glob_pattern = f'**/{pattern}' if recurse else pattern
files = [f for f in input_p.glob(glob_pattern) if f.is_file()]
self.stats['total'] = len(files)
if self.stats['total'] == 0:
print(f"在 '{input_p}' 中未找到匹配 '{pattern}' 的文件")
return
print(f"发现 {self.stats['total']} 个待处理文件\n")
output_d.mkdir(parents=True, exist_ok=True)
for idx, file in enumerate(files, 1):
if input_p.is_dir():
relative_path = file.relative_to(input_p)
output_path = output_d / relative_path
else:
output_path = output_d / file.name
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"处理中 ({idx}/{self.stats['total']}): {file}")
if self.optimize_image(file, output_path, quality, max_reduction, overwrite):
self.stats['processed'] += 1
self._print_report()
def _print_report(self):
elapsed = time.time() - self.stats['start_time']
print("\n" + "=" * 50)
print("压缩报告")
print("=" * 50)
print(f"总文件数: {self.stats['total']}")
print(f"成功处理: {self.stats['processed']}")
print(f"跳过文件: {self.stats['skipped']}")
print(f"失败文件: {self.stats['failed']}")
if self.stats['processed'] > 0 and self.stats['original_size'] > 0:
original_mb = self.stats['original_size'] / (1024 ** 2)
compressed_mb = self.stats['compressed_size'] / (1024 ** 2)
avg_ratio = (self.stats['compressed_size'] / self.stats['original_size']) * 100
print(f"\n原始大小: {original_mb:.2f} MB")
print(f"压缩大小: {compressed_mb:.2f} MB")
print(f"平均压缩率: {avg_ratio:.1f}%")
print(f"节省空间: {original_mb - compressed_mb:.2f} MB")
print(f"\n总耗时: {elapsed:.1f}秒")
print("=" * 50 + "\n")
def main():
parser = argparse.ArgumentParser(
description="智能图片批量压缩工具 v2.2",
formatter_class=argparse.RawTextHelpFormatter,
epilog="""
使用示例:
1. 压缩单个文件到 output 目录:
python %(prog)s path/to/image.jpg -o output/
2. 递归压缩 input_folder 内所有图片,初始质量为90:
python %(prog)s input_folder/ -o output_folder/ -r -q 90
3. 递归压缩 input_folder 内所有.png文件,并覆盖旧文件:
python %(prog)s input_folder/ -o output_folder/ -r --overwrite --pattern "*.png"
"""
)
parser.add_argument("input", help="输入文件或目录的路径")
parser.add_argument("-o", "--output", required=True, help="【必需】输出目录的路径")
parser.add_argument("-q", "--quality", type=int, default=85, help="初始压缩质量 (1-95),值越高图片质量越好,文件越大。默认: 85")
parser.add_argument("-m", "--max-reduction", type=int, default=25, help="从初始质量开始的最大降幅,用于智能探测。默认: 25")
parser.add_argument("-r", "--recurse", action="store_true", help="递归处理输入目录中的子目录")
parser.add_argument("--overwrite", action="store_true", help="覆盖输出目录中已存在的文件")
parser.add_argument("--pattern", default='*', help="要匹配的文件模式,如 '*.jpg' 或 'img_*.*'。默认: '*' (所有文件)")
args = parser.parse_args()
compressor = SmartImageCompressor()
compressor.batch_compress(
input_path=args.input,
output_dir=args.output,
quality=args.quality,
max_reduction=args.max_reduction,
overwrite=args.overwrite,
recurse=args.recurse,
pattern=args.pattern
)
if __name__ == "__main__":
main()
用ai生成了个gui版的,支持拖拽导入,但是我记的支持拖拽这个库对权限有要求 不要用管理员身份运行,会拖不了,输出目录要自己选
需要可以先用,后面有时间再做调整
import os
import sys
import time
import argparse
import threading
import queue
from pathlib import Path
from PIL import Image
# --- GUI 相关的导入 ---
import tkinter as tk
from tkinter import ttk, filedialog, scrolledtext
# 尝试导入拖拽库,如果失败则禁用该功能
try:
from tkinterdnd2 import DND_FILES, TkinterDnD
DND_SUPPORT = True
except ImportError:
DND_SUPPORT = False
# ==============================================================================
# 核心压缩逻辑 (与原始代码完全相同)
# ==============================================================================
class SmartImageCompressor:
def __init__(self, log_callback=None):
"""
初始化压缩器。
:param log_callback: 一个用于记录日志的回调函数,例如 print 或 GUI 的更新函数。
"""
self.stats = {
'total': 0,
'processed': 0,
'skipped': 0,
'failed': 0,
'original_size': 0,
'compressed_size': 0,
'start_time': 0
}
# 如果提供了回调,则使用它,否则使用 print
self.log = log_callback if callable(log_callback) else print
def optimize_image(self, input_path: Path, output_path: Path, quality: int, max_reduction: int,
overwrite: bool) -> bool:
if not overwrite and output_path.exists():
self.stats['skipped'] += 1
self.log(f"跳过已存在文件: {output_path}")
return False
try:
with Image.open(input_path) as img:
original_size = input_path.stat().st_size
best_quality = quality
best_size = float('inf')
if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info):
img_format = 'PNG'
img = img.convert('RGBA')
else:
img_format = 'JPEG'
img = img.convert('RGB')
# 创建一个临时文件路径用于测试
temp_output_path = output_path.with_suffix(f".tmp{output_path.suffix}")
# 从高质量向低质量迭代,寻找最优解
for q in range(quality, quality - max_reduction - 1, -5):
if q <= 0: continue
try:
save_args = {
'format': img_format,
'quality': q,
'optimize': True,
}
if img_format == 'JPEG':
save_args['progressive'] = True
elif img_format == 'PNG':
# 对于PNG, quality参数被用作压缩级别参考,但Pillow主要使用compress_level
save_args['compress_level'] = 9
img.save(temp_output_path, **save_args)
current_size = temp_output_path.stat().st_size
# 如果当前大小比已知最佳大小还大,且不是第一次迭代,说明再降质量也没用了
if current_size > best_size and best_size != float('inf'):
break
best_size = current_size
best_quality = q
except Exception as e:
self.log(f"质量 {q} 临时保存出错: {str(e)}")
break
# 使用找到的最佳质量保存最终文件
final_save_args = {
'format': img_format, 'quality': best_quality, 'optimize': True
}
if img_format == 'JPEG': final_save_args['progressive'] = True
if img_format == 'PNG': final_save_args['compress_level'] = 9
img.save(output_path, **final_save_args)
# 清理临时文件
if temp_output_path.exists():
os.remove(temp_output_path)
compressed_size = output_path.stat().st_size
if original_size == 0:
ratio = 0
else:
ratio = (compressed_size / original_size) * 100
self.stats['original_size'] += original_size
self.stats['compressed_size'] += compressed_size
self.log(f" 压缩完成: {input_path.name}")
self.log(f" 尺寸: {original_size / 1024:.1f}KB → {compressed_size / 1024:.1f}KB")
self.log(f" 压缩率: {ratio:.1f}% | 最终质量: {best_quality}\n")
return True
except Exception as e:
self.stats['failed'] += 1
self.log(f"处理失败: {input_path}")
self.log(f" 错误: {str(e)}\n")
if output_path.exists():
os.remove(output_path)
if 'temp_output_path' in locals() and temp_output_path.exists():
os.remove(temp_output_path)
return False
def batch_compress(self, input_path: str, output_dir: str, quality: int, max_reduction: int,
overwrite: bool, recurse: bool, pattern: str):
self.stats['start_time'] = time.time()
input_p = Path(input_path).resolve()
output_d = Path(output_dir).resolve()
if not input_p.exists():
self.log(f"输入路径不存在: {input_p}")
return
if input_p.is_file():
files = [input_p]
else:
glob_pattern = f'**/{pattern}' if recurse else pattern
files = [f for f in input_p.glob(glob_pattern) if
f.is_file() and f.suffix.lower() in ['.jpg', '.jpeg', '.png', '.webp']]
self.stats['total'] = len(files)
if self.stats['total'] == 0:
self.log(f"在 '{input_p}' 中未找到匹配 '{pattern}' 的图片文件")
return
self.log(f"发现 {self.stats['total']} 个待处理文件\n")
output_d.mkdir(parents=True, exist_ok=True)
for idx, file in enumerate(files, 1):
if input_p.is_dir():
relative_path = file.relative_to(input_p)
output_path = output_d / relative_path
else:
output_path = output_d / file.name
output_path.parent.mkdir(parents=True, exist_ok=True)
self.log(f"处理中 ({idx}/{self.stats['total']}): {file}")
if self.optimize_image(file, output_path, quality, max_reduction, overwrite):
self.stats['processed'] += 1
self._print_report()
def _print_report(self):
elapsed = time.time() - self.stats['start_time']
self.log("\n" + "=" * 50)
self.log("压缩报告")
self.log("=" * 50)
self.log(f"总文件数: {self.stats['total']}")
self.log(f"成功处理: {self.stats['processed']}")
self.log(f"跳过文件: {self.stats['skipped']}")
self.log(f"失败文件: {self.stats['failed']}")
if self.stats['processed'] > 0 and self.stats['original_size'] > 0:
original_mb = self.stats['original_size'] / (1024 ** 2)
compressed_mb = self.stats['compressed_size'] / (1024 ** 2)
avg_ratio = (self.stats['compressed_size'] / self.stats['original_size']) * 100
self.log(f"\n原始大小: {original_mb:.2f} MB")
self.log(f"压缩大小: {compressed_mb:.2f} MB")
self.log(f"平均压缩率: {avg_ratio:.1f}%")
self.log(f"节省空间: {original_mb - compressed_mb:.2f} MB")
self.log(f"\n总耗时: {elapsed:.1f}秒")
self.log("=" * 50 + "\n")
# ==============================================================================
# GUI 应用部分
# ==============================================================================
class CompressorApp:
def __init__(self, master):
self.master = master
self.master.title("智能图片批量压缩工具 v3.0 (GUI)")
self.master.geometry("700x600")
# 使主窗口的行列可伸缩
self.master.grid_rowconfigure(2, weight=1)
self.master.grid_columnconfigure(1, weight=1)
self.log_queue = queue.Queue()
self._create_widgets()
if DND_SUPPORT:
self.master.drop_target_register(DND_FILES)
self.master.dnd_bind('<<Drop>>', self.handle_drop)
self.master.after(100, self.process_log_queue)
def _create_widgets(self):
# --- Frame for Paths ---
path_frame = ttk.LabelFrame(self.master, text="路径设置", padding="10")
path_frame.grid(row=0, column=0, columnspan=3, padx=10, pady=5, sticky="ew")
path_frame.grid_columnconfigure(1, weight=1)
ttk.Label(path_frame, text="输入文件/目录:").grid(row=0, column=0, sticky="w", padx=5, pady=5)
self.input_path = tk.StringVar()
ttk.Entry(path_frame, textvariable=self.input_path).grid(row=0, column=1, sticky="ew", padx=5)
ttk.Button(path_frame, text="浏览...", command=self.select_input).grid(row=0, column=2, padx=5)
ttk.Label(path_frame, text="输出目录:").grid(row=1, column=0, sticky="w", padx=5, pady=5)
self.output_path = tk.StringVar()
ttk.Entry(path_frame, textvariable=self.output_path).grid(row=1, column=1, sticky="ew", padx=5)
ttk.Button(path_frame, text="浏览...", command=self.select_output).grid(row=1, column=2, padx=5)
# --- Frame for Options ---
options_frame = ttk.LabelFrame(self.master, text="压缩选项", padding="10")
options_frame.grid(row=1, column=0, columnspan=3, padx=10, pady=5, sticky="ew")
self.quality = tk.IntVar(value=85)
self.max_reduction = tk.IntVar(value=25)
self.pattern = tk.StringVar(value="*.*")
self.recurse = tk.BooleanVar(value=True)
self.overwrite = tk.BooleanVar(value=False)
ttk.Label(options_frame, text="初始质量(1-95):").grid(row=0, column=0, sticky="w", padx=5, pady=2)
ttk.Entry(options_frame, textvariable=self.quality, width=10).grid(row=0, column=1, sticky="w", padx=5)
ttk.Label(options_frame, text="最大降幅:").grid(row=0, column=2, sticky="w", padx=5, pady=2)
ttk.Entry(options_frame, textvariable=self.max_reduction, width=10).grid(row=0, column=3, sticky="w", padx=5)
ttk.Label(options_frame, text="文件模式:").grid(row=1, column=0, sticky="w", padx=5, pady=2)
ttk.Entry(options_frame, textvariable=self.pattern, width=10).grid(row=1, column=1, sticky="w", padx=5)
ttk.Checkbutton(options_frame, text="递归子目录", variable=self.recurse).grid(row=1, column=2, sticky="w",
padx=5)
ttk.Checkbutton(options_frame, text="覆盖已存在文件", variable=self.overwrite).grid(row=1, column=3, sticky="w",
padx=5)
# --- Log Area ---
log_frame = ttk.LabelFrame(self.master, text="日志输出", padding="10")
log_frame.grid(row=2, column=0, columnspan=3, padx=10, pady=5, sticky="nsew")
log_frame.grid_rowconfigure(0, weight=1)
log_frame.grid_columnconfigure(0, weight=1)
self.log_text = scrolledtext.ScrolledText(log_frame, wrap=tk.WORD, state='disabled')
self.log_text.grid(row=0, column=0, sticky="nsew")
# --- Action Button ---
self.start_button = ttk.Button(self.master, text="开始压缩", command=self.start_compression_thread)
self.start_button.grid(row=3, column=0, columnspan=3, padx=10, pady=10, ipady=5, sticky="ew")
# --- Drag and Drop Info ---
dnd_info_text = "支持拖拽文件/文件夹到此窗口" if DND_SUPPORT else "拖拽功能不可用 (请安装 tkinterdnd2)"
dnd_info_label = ttk.Label(self.master, text=dnd_info_text, style="TLabel")
dnd_info_label.grid(row=4, column=0, columnspan=3, padx=10, pady=(0, 5), sticky="w")
permission_info_label = ttk.Label(self.master,
text="",
foreground="gray")
permission_info_label.grid(row=5, column=0, columnspan=3, padx=10, pady=(0, 10), sticky="w")
def select_input(self):
# 允许用户选择文件或目录
path = filedialog.askopenfilename()
if not path:
path = filedialog.askdirectory()
if path:
self.input_path.set(path)
def select_output(self):
path = filedialog.askdirectory()
if path:
self.output_path.set(path)
def handle_drop(self, event):
# tkinterdnd2返回的路径可能包含{},需要清理
path = event.data.strip('{}')
self.input_path.set(path)
self.log_to_gui(f"已拖拽输入路径: {path}\n")
def log_to_gui(self, message):
"""线程安全地将消息放入队列"""
self.log_queue.put(message)
def process_log_queue(self):
"""从队列中获取消息并更新GUI文本框"""
try:
while True:
message = self.log_queue.get_nowait()
self.log_text.config(state='normal')
self.log_text.insert(tk.END, message)
self.log_text.see(tk.END)
self.log_text.config(state='disabled')
except queue.Empty:
pass
finally:
self.master.after(100, self.process_log_queue)
def start_compression_thread(self):
# 从GUI获取参数
input_path = self.input_path.get()
output_dir = self.output_path.get()
try:
quality = self.quality.get()
max_reduction = self.max_reduction.get()
except tk.TclError:
self.log_to_gui("错误: 质量和最大降幅必须是整数。\n")
return
overwrite = self.overwrite.get()
recurse = self.recurse.get()
pattern = self.pattern.get()
if not input_path or not output_dir:
self.log_to_gui("错误: 请先选择输入和输出路径。\n")
return
# 清空日志区域
self.log_text.config(state='normal')
self.log_text.delete(1.0, tk.END)
self.log_text.config(state='disabled')
# 禁用按钮,防止重复点击
self.start_button.config(state='disabled', text="压缩中...")
# 创建并启动后台线程执行压缩任务
thread = threading.Thread(
target=self.run_compression_task,
args=(input_path, output_dir, quality, max_reduction, overwrite, recurse, pattern),
daemon=True
)
thread.start()
def run_compression_task(self, *args):
"""此函数在后台线程中运行"""
try:
# 这里的log_callback将所有print重定向到GUI
compressor = SmartImageCompressor(log_callback=self.log_to_gui)
compressor.batch_compress(*args)
except Exception as e:
self.log_to_gui(f"\n发生严重错误: {e}\n")
finally:
# 任务完成后恢复按钮状态
self.start_button.config(state='normal', text="开始压缩")
# ==============================================================================
# 命令行接口 (与原始代码基本相同)
# ==============================================================================
def main_cli():
parser = argparse.ArgumentParser(
description="智能图片批量压缩工具 v2.2 (CLI)",
formatter_class=argparse.RawTextHelpFormatter,
epilog="""
使用示例:
1. 压缩单个文件到 output 目录:
python %(prog)s path/to/image.jpg -o output/
2. 递归压缩 input_folder 内所有图片,初始质量为90:
python %(prog)s input_folder/ -o output_folder/ -r -q 90
3. 递归压缩 input_folder 内所有.png文件,并覆盖旧文件:
python %(prog)s input_folder/ -o output_folder/ -r --overwrite --pattern "*.png"
"""
)
parser.add_argument("input", help="输入文件或目录的路径")
parser.add_argument("-o", "--output", required=True, help="【必需】输出目录的路径")
parser.add_argument("-q", "--quality", type=int, default=85,
help="初始压缩质量 (1-95),值越高图片质量越好,文件越大。默认: 85")
parser.add_argument("-m", "--max-reduction", type=int, default=25,
help="从初始质量开始的最大降幅,用于智能探测。默认: 25")
parser.add_argument("-r", "--recurse", action="store_true", help="递归处理输入目录中的子目录")
parser.add_argument("--overwrite", action="store_true", help="覆盖输出目录中已存在的文件")
parser.add_argument("--pattern", default='*.*', help="要匹配的文件模式,如 '*.jpg' 或 'img_*.*'。默认: '*.*'")
args = parser.parse_args()
# CLI模式下,log_callback为None,将使用默认的print
compressor = SmartImageCompressor()
compressor.batch_compress(
input_path=args.input,
output_dir=args.output,
quality=args.quality,
max_reduction=args.max_reduction,
overwrite=args.overwrite,
recurse=args.recurse,
pattern=args.pattern
)
def main_gui():
if not DND_SUPPORT:
print("警告: 未找到 'tkinterdnd2' 库,拖拽功能将不可用。")
print("请使用 'pip install tkinterdnd2' 进行安装。")
root = tk.Tk()
else:
root = TkinterDnD.Tk() # 使用支持 DND 的 Tk 实例
app = CompressorApp(root)
root.mainloop()
if __name__ == "__main__":
# 如果检测到命令行参数(除了脚本名本身),则运行CLI版本
# 否则,启动GUI版本
if len(sys.argv) > 1:
main_cli()
else:
main_gui()
没有评论