# image_optimizer.py
# Эта программа создаёт графический интерфейс для оптимизации изображений.
# Она использует библиотеку Tkinter для интерфейса и Pillow для работы с изображениями.
# Программа поддерживает одиночную оптимизацию и пакетную обработку изображений.
# Дополнительно отображается информация об исходном изображении (разрешение и вес)
# и об оптимизированном изображении (разрешение, вес, процент уменьшения).
# Все этапы работы логируются как в отдельном текстовом окне, так и в лог-файл "image_optimizer.log".
#
# Важно: В Pillow 10 и выше константа ANTIALIAS заменена на Image.Resampling.LANCZOS.
import tkinter as tk
from tkinter import filedialog, ttk
from PIL import Image, ImageTk
import threading
import os
import logging
import io
# Функция для форматирования размера файла (в байтах) в килобайты с двумя знаками после запятой
def format_size(size_bytes):
return f"{round(size_bytes/1024, 2)} КБ"
# Настройка логирования: сообщения записываются в файл "image_optimizer.log"
logging.basicConfig(
filename="image_optimizer.log",
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(message)s"
)
# Класс для отображения всплывающих подсказок (tooltips)
class ToolTip:
def __init__(self, widget, text="Информация"):
self.widget = widget
self.text = text
self.tip_window = None
widget.bind("<Enter>", self.show_tip)
widget.bind("<Leave>", self.hide_tip)
def show_tip(self, event=None):
if self.tip_window or not self.text:
return
x, y, cx, cy = self.widget.bbox("insert")
x = x + self.widget.winfo_rootx() + 25
y = y + cy + self.widget.winfo_rooty() + 25
self.tip_window = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True)
tw.wm_geometry("+%d+%d" % (x, y))
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
font=("tahoma", "8", "normal"))
label.pack(ipadx=1)
def hide_tip(self, event=None):
if self.tip_window:
self.tip_window.destroy()
self.tip_window = None
# Главный класс приложения
class ImageOptimizerApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("Оптимизатор изображений")
self.geometry("900x750")
# Переменные для одиночного режима
self.original_image = None # PIL Image исходного изображения
self.optimized_image = None # PIL Image оптимизированного изображения
self.original_file_path = "" # Путь к исходному изображению
# Переменные для пакетной обработки
self.input_folder = ""
self.output_folder = ""
# Создаём глобальные настройки
self.create_global_settings_frame()
# Создаем вкладки для одиночного и пакетного режимов
self.notebook = ttk.Notebook(self)
self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Вкладка для одиночного режима
self.single_frame = ttk.Frame(self.notebook)
self.notebook.add(self.single_frame, text="Одиночный режим")
self.create_single_mode_frame()
# Вкладка для пакетного режима
self.batch_frame = ttk.Frame(self.notebook)
self.notebook.add(self.batch_frame, text="Пакетный режим")
self.create_batch_mode_frame()
# Область для логирования
self.create_log_frame()
def create_global_settings_frame(self):
frame = ttk.LabelFrame(self, text="Глобальные настройки")
frame.pack(fill=tk.X, padx=10, pady=5)
# Выбор формата сжатия: JPEG, PNG, WEBP
self.format_var = tk.StringVar(value="JPEG")
ttk.Label(frame, text="Формат:").grid(row=0, column=0, padx=5, pady=5, sticky="w")
formats = [("JPEG", "JPEG"), ("PNG", "PNG"), ("WEBP", "WEBP")]
col = 1
for text, mode in formats:
rb = ttk.Radiobutton(frame, text=text, variable=self.format_var, value=mode)
rb.grid(row=0, column=col, padx=5, pady=5, sticky="w")
col += 1
# Ползунок для настройки качества сжатия (1-100)
ttk.Label(frame, text="Качество:").grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.quality_scale = tk.Scale(frame, from_=1, to=100, orient=tk.HORIZONTAL)
self.quality_scale.set(85)
self.quality_scale.grid(row=1, column=1, columnspan=3, padx=5, pady=5, sticky="w")
ToolTip(self.quality_scale, "Более высокие значения сохраняют больше деталей, но увеличивают размер файла. Более низкие значения уменьшают размер за счёт потерь.")
# Ползунок для уменьшения разрешения (коэффициент)
ttk.Label(frame, text="Коэффициент уменьшения разрешения:").grid(row=2, column=0, padx=5, pady=5, sticky="w")
self.resolution_scale = tk.Scale(frame, from_=1, to=5, orient=tk.HORIZONTAL)
self.resolution_scale.set(1)
self.resolution_scale.grid(row=2, column=1, columnspan=3, padx=5, pady=5, sticky="w")
def create_single_mode_frame(self):
# Фрейм с кнопками
control_frame = ttk.Frame(self.single_frame)
control_frame.pack(fill=tk.X, padx=10, pady=10)
load_button = ttk.Button(control_frame, text="Загрузить изображение", command=self.load_image)
load_button.grid(row=0, column=0, padx=5, pady=5)
optimize_button = ttk.Button(control_frame, text="Оптимизировать", command=self.optimize_image)
optimize_button.grid(row=0, column=1, padx=5, pady=5)
self.save_button = ttk.Button(control_frame, text="Сохранить изображение", command=self.save_image, state=tk.DISABLED)
self.save_button.grid(row=0, column=2, padx=5, pady=5)
clear_button = ttk.Button(control_frame, text="Очистить", command=self.clear_single_mode)
clear_button.grid(row=0, column=3, padx=5, pady=5)
# Предпросмотр изображения
self.single_preview_label = ttk.Label(self.single_frame)
self.single_preview_label.pack(padx=10, pady=10)
# Метки для отображения информации об изображении
self.single_info_label = ttk.Label(self.single_frame, text="", foreground="blue")
self.single_info_label.pack(padx=10, pady=2)
self.single_opt_info_label = ttk.Label(self.single_frame, text="", foreground="green")
self.single_opt_info_label.pack(padx=10, pady=2)
def create_batch_mode_frame(self):
# Фрейм для выбора папок
folder_frame = ttk.Frame(self.batch_frame)
folder_frame.pack(fill=tk.X, padx=10, pady=10)
input_button = ttk.Button(folder_frame, text="Выбрать входную папку", command=self.select_input_folder)
input_button.grid(row=0, column=0, padx=5, pady=5)
self.input_folder_label = ttk.Label(folder_frame, text="Не выбрана")
self.input_folder_label.grid(row=0, column=1, padx=5, pady=5)
output_button = ttk.Button(folder_frame, text="Выбрать выходную папку", command=self.select_output_folder)
output_button.grid(row=1, column=0, padx=5, pady=5)
self.output_folder_label = ttk.Label(folder_frame, text="Не выбрана")
self.output_folder_label.grid(row=1, column=1, padx=5, pady=5)
process_button = ttk.Button(self.batch_frame, text="Запустить обработку", command=self.start_batch_processing)
process_button.pack(padx=10, pady=10)
self.batch_status_label = ttk.Label(self.batch_frame, text="Обработано: 0 из 0")
self.batch_status_label.pack(padx=10, pady=5)
# Метка для предпросмотра последнего обработанного изображения
self.batch_preview_label = ttk.Label(self.batch_frame)
self.batch_preview_label.pack(padx=10, pady=10)
# Метка для вывода сводной информации по пакетной обработке
self.batch_summary_label = ttk.Label(self.batch_frame, text="", foreground="purple")
self.batch_summary_label.pack(padx=10, pady=5)
def create_log_frame(self):
log_frame = ttk.LabelFrame(self, text="Лог работы")
log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
self.log_text = tk.Text(log_frame, height=10)
self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar = ttk.Scrollbar(log_frame, orient=tk.VERTICAL, command=self.log_text.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.log_text.config(yscrollcommand=scrollbar.set)
def log_message(self, message):
logging.info(message)
self.log_text.insert(tk.END, message + "\n")
self.log_text.see(tk.END)
def load_image(self):
file_path = filedialog.askopenfilename(
title="Выберите изображение",
filetypes=[("Image Files", "*.jpg *.jpeg *.png *.webp")]
)
if file_path:
try:
self.original_image = Image.open(file_path)
self.original_file_path = file_path
self.log_message(f"Загружено изображение: {file_path}")
self.show_image_preview(self.original_image, mode="single")
# Получаем информацию об исходном изображении
orig_width, orig_height = self.original_image.size
orig_size = os.path.getsize(file_path)
info_text = f"Исходное изображение: {orig_width}x{orig_height} пикселей, вес: {format_size(orig_size)}"
self.single_info_label.config(text=info_text)
# Сбрасываем информацию по оптимизированному изображению
self.single_opt_info_label.config(text="")
except Exception as e:
self.log_message(f"Ошибка загрузки изображения: {e}")
def show_image_preview(self, image, mode="single"):
preview_image = image.copy()
preview_image.thumbnail((400, 400))
photo = ImageTk.PhotoImage(preview_image)
if mode == "single":
self.single_preview_label.config(image=photo)
self.single_preview_label.image = photo
elif mode == "batch":
self.batch_preview_label.config(image=photo)
self.batch_preview_label.image = photo
def optimize_image(self):
if self.original_image is None:
self.log_message("Сначала загрузите изображение для оптимизации.")
return
try:
format_choice = self.format_var.get()
quality = self.quality_scale.get()
resolution_factor = self.resolution_scale.get()
self.log_message(f"Оптимизация изображения: формат={format_choice}, качество={quality}, коэффициент уменьшения={resolution_factor}")
# Изменяем разрешение изображения
new_width = self.original_image.width // resolution_factor
new_height = self.original_image.height // resolution_factor
optimized = self.original_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Сохраняем оптимизированное изображение во временный буфер
buffer = io.BytesIO()
if format_choice == "JPEG":
optimized.save(buffer, format=format_choice, quality=quality)
else:
optimized.save(buffer, format=format_choice)
optimized_data = buffer.getvalue()
buffer.seek(0)
self.optimized_image = Image.open(buffer)
# Вычисляем размеры файлов
orig_size = os.path.getsize(self.original_file_path)
opt_size = len(optimized_data)
reduction = round((1 - opt_size/orig_size) * 100, 2)
info_text = f"Оптимизированное изображение: {new_width}x{new_height} пикселей, вес: {format_size(opt_size)}, снижение размера: {reduction}%"
self.single_opt_info_label.config(text=info_text)
self.log_message("Изображение успешно оптимизировано.")
self.show_image_preview(self.optimized_image, mode="single")
self.save_button.config(state=tk.NORMAL)
except Exception as e:
self.log_message(f"Ошибка при оптимизации изображения: {e}")
def save_image(self):
if self.optimized_image is None:
self.log_message("Нет оптимизированного изображения для сохранения.")
return
format_choice = self.format_var.get()
ext = format_choice.lower() if format_choice != "JPEG" else "jpg"
file_path = filedialog.asksaveasfilename(
defaultextension=f".{ext}",
filetypes=[(f"{format_choice} files", f"*.{ext}")]
)
if file_path:
try:
quality = self.quality_scale.get()
if format_choice == "JPEG":
self.optimized_image.save(file_path, format=format_choice, quality=quality)
else:
self.optimized_image.save(file_path, format=format_choice)
self.log_message(f"Изображение сохранено: {file_path}")
except Exception as e:
self.log_message(f"Ошибка сохранения изображения: {e}")
def clear_single_mode(self):
self.original_image = None
self.optimized_image = None
self.original_file_path = ""
self.single_preview_label.config(image="")
self.save_button.config(state=tk.DISABLED)
self.single_info_label.config(text="")
self.single_opt_info_label.config(text="")
self.log_message("Одиночный режим очищен.")
def select_input_folder(self):
folder = filedialog.askdirectory(title="Выберите входную папку")
if folder:
self.input_folder = folder
self.input_folder_label.config(text=folder)
self.log_message(f"Выбрана входная папка: {folder}")
def select_output_folder(self):
folder = filedialog.askdirectory(title="Выберите выходную папку")
if folder:
self.output_folder = folder
self.output_folder_label.config(text=folder)
self.log_message(f"Выбрана выходная папка: {folder}")
def start_batch_processing(self):
if not self.input_folder or not self.output_folder:
self.log_message("Необходимо выбрать входную и выходную папки.")
return
# Сброс сводной информации
self.batch_summary_label.config(text="")
thread = threading.Thread(target=self.batch_process)
thread.start()
def batch_process(self):
try:
files = os.listdir(self.input_folder)
image_extensions = (".jpg", ".jpeg", ".png", ".webp")
image_files = [f for f in files if f.lower().endswith(image_extensions)]
total_files = len(image_files)
processed_count = 0
if total_files == 0:
self.log_message("В выбранной папке не найдено изображений.")
return
# Суммарные размеры исходных и оптимизированных файлов
total_original_size = 0
total_optimized_size = 0
self.log_message(f"Начинается пакетная обработка {total_files} изображений.")
for filename in image_files:
try:
file_path = os.path.join(self.input_folder, filename)
image = Image.open(file_path)
orig_size = os.path.getsize(file_path)
total_original_size += orig_size
format_choice = self.format_var.get()
quality = self.quality_scale.get()
resolution_factor = self.resolution_scale.get()
new_width = image.width // resolution_factor
new_height = image.height // resolution_factor
optimized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Сохраняем оптимизированное изображение во временный буфер
buffer = io.BytesIO()
if format_choice == "JPEG":
optimized.save(buffer, format=format_choice, quality=quality)
else:
optimized.save(buffer, format=format_choice)
optimized_data = buffer.getvalue()
opt_size = len(optimized_data)
total_optimized_size += opt_size
# Записываем оптимизированное изображение на диск
output_filename = os.path.splitext(filename)[0]
if format_choice == "JPEG":
output_filename += ".jpg"
else:
output_filename += f".{format_choice.lower()}"
output_path = os.path.join(self.output_folder, output_filename)
with open(output_path, "wb") as f:
f.write(optimized_data)
processed_count += 1
self.after(0, self.update_batch_status, processed_count, total_files)
self.after(0, self.show_image_preview, optimized, "batch")
self.log_message(f"Обработано изображение: {filename}")
except Exception as e:
self.log_message(f"Ошибка обработки файла {filename}: {e}")
# Вычисляем процентное снижение размера для всей партии
reduction = round((1 - total_optimized_size/total_original_size) * 100, 2) if total_original_size > 0 else 0
summary_text = (f"Обработка завершена.\n"
f"Исходный общий размер: {format_size(total_original_size)}\n"
f"Оптимизированный общий размер: {format_size(total_optimized_size)}\n"
f"Снижение размера: {reduction}%")
self.after(0, self.batch_summary_label.config, {"text": summary_text})
self.log_message("Пакетная обработка завершена.")
except Exception as e:
self.log_message(f"Ошибка пакетной обработки: {e}")
def update_batch_status(self, processed, total):
self.batch_status_label.config(text=f"Обработано: {processed} из {total}")
if __name__ == "__main__":
app = ImageOptimizerApp()
app.mainloop()