DEV Community

Cover image for How I Reduced PNG Image Size by 90% Using Pillow and WebP
Tarun Kumar
Tarun Kumar

Posted on

How I Reduced PNG Image Size by 90% Using Pillow and WebP

Images are one of the biggest contributors to website load times. Large image files increase bandwidth consumption, slow down page rendering, and negatively affect user experience and SEO rankings.

Recently, I worked on an image optimization solution that automatically converts PNG images into WebP format using Python's Pillow library and PostgreSQL-backed storage. The results were impressive: a PNG image of 1.23 MB was reduced to just 55 KB, achieving more than 90% file size reduction while maintaining excellent visual quality.

Why Image Optimization Matters

Optimized images provide several benefits:

  • Faster website loading speed
  • Improved user experience
  • Reduced server storage requirements
  • Lower bandwidth consumption
  • Better SEO performance
  • Improved Core Web Vitals scores

For websites that handle thousands of images, these savings can significantly reduce infrastructure costs.

The Problem

Many applications store images in PNG format because it supports transparency and high-quality visuals. However, PNG files can become quite large, especially when uploaded directly from modern devices or design tools.

For example:

Format File Size
Original PNG 1.23 MB
Optimized WebP 55 KB

This single conversion saved approximately 95% of the original file size.

The Solution: Pillow + WebP Conversion

I used Python's Pillow library to automatically convert uploaded PNG images into WebP format.

Benefits of WebP

WebP is a modern image format developed by Google that offers:

  • Smaller file sizes
  • High visual quality
  • Transparency support
  • Better compression than PNG and JPEG
  • Broad browser compatibility

Sample Conversion Logic

import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk
import psycopg2
from psycopg2.extras import RealDictCursor
import uuid
import os
from io import BytesIO
from datetime import datetime
import threading

class DatabaseManager:
    def __init__(self, dbname="image_compressor", user="postgres", password="12345", host="localhost", port="5432"):
        """Initialize database connection"""
        self.connection_params = {
            'dbname': dbname,
            'user': user,
            'password': password,
            'host': host,
            'port': port
        }
        self.create_table()

    def get_connection(self):
        """Get database connection"""
        return psycopg2.connect(**self.connection_params)

    def create_table(self):
        """Create the compressed_images table if it doesn't exist"""
        try:
            conn = self.get_connection()
            cursor = conn.cursor()
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS compressed_images (
                    id UUID PRIMARY KEY,
                    original_path TEXT NOT NULL,
                    compressed_path TEXT NOT NULL,
                    original_size INTEGER DEFAULT 0,
                    compressed_size INTEGER DEFAULT 0,
                    compression_quality INTEGER DEFAULT 85,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            conn.commit()
            cursor.close()
            conn.close()
        except Exception as e:
            print(f"Error creating table: {e}")

    def save_compression_record(self, original_path, compressed_path, original_size, compressed_size, quality):
        """Save compression record to database"""
        try:
            conn = self.get_connection()
            cursor = conn.cursor()
            record_id = uuid.uuid4()
            cursor.execute("""
                INSERT INTO compressed_images 
                (id, original_path, compressed_path, original_size, compressed_size, compression_quality)
                VALUES (%s, %s, %s, %s, %s, %s)
            """, (str(record_id), original_path, compressed_path, original_size, compressed_size, quality))
            conn.commit()
            cursor.close()
            conn.close()
            return record_id
        except Exception as e:
            print(f"Error saving record: {e}")
            return None

    def get_history(self, limit=50):
        """Get compression history"""
        try:
            conn = self.get_connection()
            cursor = conn.cursor(cursor_factory=RealDictCursor)
            cursor.execute("""
                SELECT * FROM compressed_images 
                ORDER BY created_at DESC 
                LIMIT %s
            """, (limit,))
            records = cursor.fetchall()
            cursor.close()
            conn.close()
            return records
        except Exception as e:
            print(f"Error fetching history: {e}")
            return []


class ImageCompressor:
    @staticmethod
    def compress_to_webp(input_path, output_path, quality=85):
        """Compress image to WebP format"""
        try:
            img = Image.open(input_path)

            # Convert RGBA/P to RGB
            if img.mode in ("RGBA", "P"):
                img = img.convert("RGB")

            # Save as WebP
            img.save(output_path, format='WEBP', quality=quality, optimize=True)

            # Get file sizes
            original_size = os.path.getsize(input_path)
            compressed_size = os.path.getsize(output_path)

            return original_size, compressed_size
        except Exception as e:
            raise Exception(f"Error compressing image: {str(e)}")

    @staticmethod
    def calculate_reduction_percentage(original_size, compressed_size):
        """Calculate size reduction percentage"""
        if original_size and compressed_size:
            return round(((original_size - compressed_size) / original_size) * 100, 2)
        return 0

    @staticmethod
    def format_file_size(size_bytes):
        """Format file size to human readable format"""
        for unit in ['B', 'KB', 'MB', 'GB']:
            if size_bytes < 1024.0:
                return f"{size_bytes:.2f} {unit}"
            size_bytes /= 1024.0
        return f"{size_bytes:.2f} TB"


class ImageCompressorApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Image Compressor - WebP Converter")
        self.root.geometry("900x700")
        self.root.resizable(False, False)

        # Initialize database
        self.db = DatabaseManager()

        # Variables
        self.selected_file = None
        self.compressed_file_path = None
        self.quality_var = tk.IntVar(value=60)

        # Create output directory
        self.output_dir = os.path.join(os.path.expanduser("~"), "CompressedImages")
        os.makedirs(self.output_dir, exist_ok=True)

        self.setup_ui()

    def setup_ui(self):
        """Setup the user interface"""
        # Title
        title_frame = tk.Frame(self.root, bg="#2c3e50", height=80)
        title_frame.pack(fill="x")
        title_frame.pack_propagate(False)

        title_label = tk.Label(
            title_frame, 
            text="Image Compressor", 
            font=("Arial", 24, "bold"),
            bg="#2c3e50",
            fg="white"
        )
        title_label.pack(pady=20)

        # Main container
        main_frame = tk.Frame(self.root, padx=20, pady=20)
        main_frame.pack(fill="both", expand=True)

        # File selection section
        file_frame = tk.LabelFrame(main_frame, text="Select Image", font=("Arial", 12, "bold"), padx=10, pady=10)
        file_frame.pack(fill="x", pady=(0, 15))

        self.file_label = tk.Label(file_frame, text="No file selected", font=("Arial", 10), fg="gray")
        self.file_label.pack(side="left", padx=5)

        select_btn = tk.Button(
            file_frame, 
            text="Browse", 
            command=self.select_file,
            bg="#3498db",
            fg="white",
            font=("Arial", 10, "bold"),
            padx=20,
            pady=5,
            cursor="hand2"
        )
        select_btn.pack(side="right", padx=5)

        # Quality slider section
        quality_frame = tk.LabelFrame(main_frame, text="Compression Quality", font=("Arial", 12, "bold"), padx=10, pady=10)
        quality_frame.pack(fill="x", pady=(0, 15))

        slider_container = tk.Frame(quality_frame)
        slider_container.pack(fill="x")

        self.quality_label = tk.Label(slider_container, text="60", font=("Arial", 14, "bold"), fg="#2c3e50")
        self.quality_label.pack(side="right", padx=10)

        quality_slider = tk.Scale(
            slider_container,
            from_=10,
            to=100,
            orient="horizontal",
            variable=self.quality_var,
            command=self.update_quality_label,
            length=400,
            sliderlength=30,
            font=("Arial", 9)
        )
        quality_slider.pack(side="left", fill="x", expand=True)

        # Compress button
        self.compress_btn = tk.Button(
            main_frame,
            text="Compress Image",
            command=self.compress_image,
            bg="#27ae60",
            fg="white",
            font=("Arial", 12, "bold"),
            padx=30,
            pady=10,
            cursor="hand2",
            state="disabled"
        )
        self.compress_btn.pack(pady=15)

        # Results section
        self.results_frame = tk.LabelFrame(main_frame, text="Compression Results", font=("Arial", 12, "bold"), padx=15, pady=15)
        self.results_frame.pack(fill="both", expand=True, pady=(0, 15))

        self.results_text = tk.Text(self.results_frame, height=8, font=("Arial", 10), state="disabled", wrap="word")
        self.results_text.pack(fill="both", expand=True)

        # Action buttons
        button_frame = tk.Frame(main_frame)
        button_frame.pack(fill="x")

        self.open_btn = tk.Button(
            button_frame,
            text="Open Compressed Image",
            command=self.open_compressed_file,
            bg="#9b59b6",
            fg="white",
            font=("Arial", 10, "bold"),
            padx=15,
            pady=8,
            cursor="hand2",
            state="disabled"
        )
        self.open_btn.pack(side="left", padx=5)

        self.save_as_btn = tk.Button(
            button_frame,
            text="Save As...",
            command=self.save_as,
            bg="#e67e22",
            fg="white",
            font=("Arial", 10, "bold"),
            padx=15,
            pady=8,
            cursor="hand2",
            state="disabled"
        )
        self.save_as_btn.pack(side="left", padx=5)

        history_btn = tk.Button(
            button_frame,
            text="View History",
            command=self.show_history,
            bg="#34495e",
            fg="white",
            font=("Arial", 10, "bold"),
            padx=15,
            pady=8,
            cursor="hand2"
        )
        history_btn.pack(side="right", padx=5)

    def update_quality_label(self, value):
        """Update quality label when slider moves"""
        self.quality_label.config(text=str(int(float(value))))

    def select_file(self):
        """Open file dialog to select image"""
        filetypes = (
            ('Image files', '*.jpg *.jpeg *.png'),
            ('JPEG files', '*.jpg *.jpeg'),
            ('PNG files', '*.png'),
        )

        filename = filedialog.askopenfilename(
            title='Select an image',
            filetypes=filetypes
        )

        if filename:
            # Check file size (max 10MB)
            file_size = os.path.getsize(filename)
            if file_size > 10 * 1024 * 1024:
                messagebox.showerror("Error", "Image file too large (> 10MB)")
                return

            self.selected_file = filename
            self.file_label.config(text=os.path.basename(filename), fg="black")
            self.compress_btn.config(state="normal")

    def compress_image(self):
        """Compress the selected image"""
        if not self.selected_file:
            messagebox.showwarning("Warning", "Please select an image first")
            return

        # Disable button during compression
        self.compress_btn.config(state="disabled", text="Compressing...")
        self.root.update()

        def compress_task():
            try:
                # Generate output filename
                output_filename = f"compressed_{uuid.uuid4()}.webp"
                output_path = os.path.join(self.output_dir, output_filename)

                # Compress image
                quality = self.quality_var.get()
                original_size, compressed_size = ImageCompressor.compress_to_webp(
                    self.selected_file,
                    output_path,
                    quality
                )

                # Calculate reduction
                reduction = ImageCompressor.calculate_reduction_percentage(original_size, compressed_size)

                # Save to database
                self.db.save_compression_record(
                    self.selected_file,
                    output_path,
                    original_size,
                    compressed_size,
                    quality
                )

                # Store compressed file path
                self.compressed_file_path = output_path

                # Update UI on main thread
                self.root.after(0, lambda: self.show_results(original_size, compressed_size, reduction, output_path))

            except Exception as e:
                self.root.after(0, lambda: messagebox.showerror("Error", f"Compression failed: {str(e)}"))
                self.root.after(0, lambda: self.compress_btn.config(state="normal", text="Compress Image"))

        # Run compression in separate thread
        thread = threading.Thread(target=compress_task, daemon=True)
        thread.start()

    def show_results(self, original_size, compressed_size, reduction, output_path):
        """Display compression results"""
        self.results_text.config(state="normal")
        self.results_text.delete(1.0, tk.END)

        results = f"""
✓ Compression Successful!

Original Size:     {ImageCompressor.format_file_size(original_size)}
Compressed Size:   {ImageCompressor.format_file_size(compressed_size)}
Size Reduction:    {reduction}%
Quality:           {self.quality_var.get()}%

Output Location:   {output_path}
        """

        self.results_text.insert(1.0, results.strip())
        self.results_text.config(state="disabled")

        # Enable action buttons
        self.open_btn.config(state="normal")
        self.save_as_btn.config(state="normal")
        self.compress_btn.config(state="normal", text="Compress Image")

        messagebox.showinfo("Success", f"Image compressed successfully!\nSize reduced by {reduction}%")

    def open_compressed_file(self):
        """Open the compressed image file"""
        if self.compressed_file_path and os.path.exists(self.compressed_file_path):
            os.startfile(self.compressed_file_path)
        else:
            messagebox.showerror("Error", "Compressed file not found")

    def save_as(self):
        """Save compressed image to a custom location"""
        if not self.compressed_file_path or not os.path.exists(self.compressed_file_path):
            messagebox.showerror("Error", "No compressed file available")
            return

        save_path = filedialog.asksaveasfilename(
            defaultextension=".webp",
            filetypes=[("WebP files", "*.webp"), ("All files", "*.*")],
            initialfile="compressed_image.webp"
        )

        if save_path:
            try:
                import shutil
                shutil.copy2(self.compressed_file_path, save_path)
                messagebox.showinfo("Success", f"Image saved to:\n{save_path}")
            except Exception as e:
                messagebox.showerror("Error", f"Failed to save file: {str(e)}")

    def show_history(self):
        """Show compression history in a new window"""
        history_window = tk.Toplevel(self.root)
        history_window.title("Compression History")
        history_window.geometry("800x500")

        # Create treeview
        tree_frame = tk.Frame(history_window)
        tree_frame.pack(fill="both", expand=True, padx=10, pady=10)

        scrollbar = tk.Scrollbar(tree_frame)
        scrollbar.pack(side="right", fill="y")

        columns = ("Date", "Original Size", "Compressed Size", "Reduction", "Quality")
        tree = ttk.Treeview(tree_frame, columns=columns, show="headings", yscrollcommand=scrollbar.set)
        scrollbar.config(command=tree.yview)

        # Configure columns
        tree.heading("Date", text="Date")
        tree.heading("Original Size", text="Original Size")
        tree.heading("Compressed Size", text="Compressed Size")
        tree.heading("Reduction", text="Reduction %")
        tree.heading("Quality", text="Quality %")

        tree.column("Date", width=150)
        tree.column("Original Size", width=120)
        tree.column("Compressed Size", width=120)
        tree.column("Reduction", width=100)
        tree.column("Quality", width=100)

        # Load history
        records = self.db.get_history()
        for record in records:
            reduction = ImageCompressor.calculate_reduction_percentage(
                record['original_size'],
                record['compressed_size']
            )
            tree.insert("", "end", values=(
                record['created_at'].strftime("%Y-%m-%d %H:%M:%S"),
                ImageCompressor.format_file_size(record['original_size']),
                ImageCompressor.format_file_size(record['compressed_size']),
                f"{reduction}%",
                f"{record['compression_quality']}%"
            ))

        tree.pack(fill="both", expand=True)

        close_btn = tk.Button(
            history_window,
            text="Close",
            command=history_window.destroy,
            bg="#e74c3c",
            fg="white",
            font=("Arial", 10, "bold"),
            padx=20,
            pady=5
        )
        close_btn.pack(pady=10)


def main():
    root = tk.Tk()
    app = ImageCompressorApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

This simple conversion can dramatically reduce image sizes while preserving visual quality.

Database Integration

The optimized images are stored and managed through PostgreSQL, allowing efficient tracking and retrieval of image records.

The workflow looks like this:

  1. User uploads a PNG image.
  2. Python processes the image.
  3. Pillow converts PNG to WebP.
  4. The optimized image is stored.
  5. PostgreSQL maintains metadata and file references.
  6. The website serves the compressed WebP image.

This ensures that only optimized assets are delivered to end users.

Eliminating Manual Work with a Windows Executable

Initially, the conversion script had to be executed manually.

To automate the process, I packaged the Python application into a standalone Windows executable (.exe). This allows non-technical users to run the optimization process without installing Python or executing scripts from the command line.

Advantages of the Executable

  • One-click execution
  • No Python installation required
  • Easy deployment across multiple systems
  • Suitable for daily operational use
  • Reduces human error

Now the optimization process can be performed daily with minimal effort.

Performance Impact

After implementing the optimization workflow:

  • Image sizes decreased by over 90%
  • Page load times improved significantly
  • Storage consumption reduced dramatically
  • User experience improved on slower networks
  • Image delivery became more efficient

Final Thoughts

Image optimization is one of the simplest and most effective ways to improve website performance. By combining Python, Pillow, PostgreSQL, and WebP conversion, it is possible to achieve massive storage and bandwidth savings with minimal development effort.

In my implementation, a 1.23 MB PNG image was reduced to just 55 KB, demonstrating the power of modern image compression techniques. Packaging the solution as a Windows executable further streamlined the workflow, making daily image optimization simple and accessible for anyone on the team.

If your application still serves large PNG files, converting them to WebP could be one of the highest-impact performance improvements you can make.

Top comments (0)