Browse by section

Python 日本語

Build an Image Compression GUI in Python (JPEG and PNG)

This article walks through building a drag-and-drop image compression GUI in Python with wxPython, handling both JPEG and PNG.

The short answer: JPEG compresses well through OpenCV imencode with a quality setting. PNG also compresses through OpenCV, but only losslessly, so the file shrinks by about 12 percent. Getting a PNG meaningfully smaller requires colour quantisation.

This started as a note-to-self published while the PNG side was still unsolved. It now has an answer, measured rather than assumed.

▼Measured on a 1200×671 PNG (977 KB)

Method Result Of original
Lossless, compress_level=9 863 KB 88.3%
Lossless, optimize=True 862 KB 88.2%
Quantised to 256 colours 101 KB 10.3%

Lossless compression removed 12 percent. Quantisation removed about 90 percent. For PNG, the compression level is not the lever that matters — the colour count is.

Sponsored

What the app does

Drop image files onto the window and it writes out several compression levels at once, so you can compare them.

  • Accepts multiple files dropped together
  • Writes seven JPEG quality steps (90 down to 10) and six PNG compression steps
  • Separates the output into folders by level
  • Logs results inside the window

The GUI is wxPython (wx) and the image work is OpenCV (cv2).

Installing the dependencies

pip install wxPython opencv-python

Add Pillow as well if you want to try the PNG quantisation covered later.

pip install Pillow

Run it from the terminal:

python filename.py

Sponsored

The full source

# -*- coding: UTF-8 -*-

import wx
import cv2
import os

# D&Dウィンドウクラス
class FileDropTarget(wx.FileDropTarget):
    """ Drag & Drop Class """
    def __init__(self, window):
        wx.FileDropTarget.__init__(self)
        self.window = window

    # DDされた画像のパスをimgComp(self, path)に渡す
    def OnDropFiles(self, x, y, files):
        self.window.pathList(files)
        return 0


class App(wx.Frame):
    """ GUI """
    def __init__(self, parent, id, title):
        wx.Frame.__init__(self, parent, id, title, size=(600, 400), style=wx.DEFAULT_FRAME_STYLE)

        # ファイルドロップエリア
        p = wx.Panel(self, wx.ID_ANY)
        label = wx.StaticText(p, wx.ID_ANY, 'ここにファイルをドロップしてください', style=wx.SIMPLE_BORDER | wx.TE_CENTER)
        label.SetBackgroundColour("#e0ffff")

        # ドロップ対象の設定
        label.SetDropTarget(FileDropTarget(self))

        # ログ表示エリア
        style = wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL
        self.text_entry = wx.TextCtrl(p, wx.ID_ANY, style = style)
        
        # レイアウト
        layout = wx.BoxSizer(wx.VERTICAL)
        layout.Add(label, flag=wx.EXPAND | wx.ALL, border=10, proportion=1)
        layout.Add(self.text_entry, flag=wx.EXPAND | wx.ALL, border=10, proportion=1)
        p.SetSizer(layout)

        self.Show()

    # 画像を保存する
    def imgWrite(self, compArray, decimg, name, ext):
        # 保存先フォルダ指定。なければ作る
        dir = os.getcwd()
        dir1 = dir + "/フォルダ名"
        if not os.path.exists(dir1):
            os.makedirs(dir1)

        dir2 = dir + "/フォルダ名/" + str(compArray)
        if not os.path.exists(dir2):
            os.makedirs(dir2)

        # 圧縮画像を保存
        fileName = name + "_" + str(compArray) + "." + ext
        cv2.imwrite(os.path.join(dir1, fileName), decimg)
        # 圧縮品質ごとに分けて保存
        fileName = name + "." + ext
        cv2.imwrite(os.path.join(dir2, fileName), decimg)
    
    # パスの画像を圧縮する
    def imgComp(self, path):
        # 入力画像の読み込み
        img = cv2.imread(path, -1)

        # jpg圧縮品質。高いほど高品質
        jpgArray = [90, 80, 70, 60, 50, 40, 10]
        # png圧縮率。値が大きいほどサイズが小さい。
        pngArray = [1, 2, 3, 7, 8 ,9]

        # ファイル名と拡張子を取得
        imgName = self.fileName(path)
        name, ext = imgName.split(".")

        if ext == "jpg" or ext == "jpeg":
            for i in range(len(jpgArray)):
                # 画像をメモリ上で圧縮
                result, encimg = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), jpgArray[i]])

                # 圧縮されたメモリ上の画像を復元
                decimg = cv2.imdecode(encimg, -1)

                # jpg保存
                self.imgWrite(jpgArray[i], decimg, name, ext)
        
        elif ext == "png":
            for i in range(len(pngArray)):
                # 画像をメモリ上で圧縮
                result, encimg = cv2.imencode(".png", img, [int(cv2.IMWRITE_PNG_COMPRESSION), pngArray[i]])

                # 圧縮されたメモリ上の画像を復元
                decimg = cv2.imdecode(encimg, -1)

                # png保存
                self.imgWrite(pngArray[i], decimg, name, ext)

        else:
            exit()
    
    # ファイル名と拡張子を取得
    def fileName(self, path):
        imgNameArray = path.split("/")
        imgName = imgNameArray[-1]
        return imgName
        
    # 受け取ったパスリストをループ処理
    def pathList(self, path):
        for i in range(len(path)):
            # 拡張子がjpgかpngかそれ以外を判定
            imgName = self.fileName(path[i])
            if ".jpg" in imgName or ".jpeg" in imgName or ".png" in imgName:
                self.imgComp(path[i])

                # ファイル名と拡張子を取得する。ログ表示用
                name, ext = imgName.split(".")
                self.text_entry.AppendText(name + "." + ext + " の圧縮成功\n")
            else:
                self.text_entry.AppendText(imgName + " は圧縮できないよ。pngかjpgをD&Dしてね。\n")
                pass



app = wx.App()
App(None, -1, "imgCompression")
app.MainLoop()

How the code is organised

Two classes: FileDropTarget receives the drop, and App holds the window and the compression logic.

▼The flow

Method Responsibility
OnDropFiles Receives the dropped file paths
pathList Iterates and filters by extension
imgComp Runs JPEG or PNG compression
imgWrite Writes results into per-level folders
fileName Extracts the filename from a path

Dropped paths travel from OnDropFiles to pathList, and only .jpg and .png files reach imgComp. Anything else is logged as unsupported.

Sponsored

Compressing JPEG

Pass IMWRITE_JPEG_QUALITY to OpenCV’s imencode. JPEG is lossy, so lowering quality reliably reduces the file size.

result, encimg = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
decimg = cv2.imdecode(encimg, -1)

imencode compresses in memory rather than writing to disk, and imdecode turns the result back into an image, so the same save routine handles both.

Quality runs from 0 to 100. Measured on a 1024×768 photograph:

▼JPEG quality against file size

Quality Size Verdict
90 221 KB No visible loss
70 112 KB The practical setting for the web
50 81 KB Artefacts visible when enlarged
10 28 KB Obvious blocking

Going from 90 to 70 halves the file with almost no visible difference. Going from 50 to 10 only cuts it by a further two thirds while destroying the image. The useful range is 90 to 70.

Compressing PNG

OpenCV’s IMWRITE_PNG_COMPRESSION does work. PNG is simply a lossless format, so the gains are small — “barely shrinks” rather than “cannot compress”.

The 0 to 9 value controls how hard the DEFLATE encoder (the same algorithm as ZIP) searches for repetition. Image quality never changes, which caps the saving in the low tens of percent.

result, encimg = cv2.imencode(".png", img, [int(cv2.IMWRITE_PNG_COMPRESSION), 9])

Measured, a 977 KB PNG came out at 863 KB (88.3%) with compress_level=9. To go further you need quantisation.

Quantising colours to cut the size

Converting a full-colour PNG to a 256-colour palette brings it down to roughly a tenth. Pillow’s quantize() does it without any external tool.

from PIL import Image

img = Image.open("input.png")

# FASTOCTREE is required for RGBA
# MEDIANCUT and MAXCOVERAGE do not support alpha
quantized = img.convert("RGBA").quantize(
    colors=256,
    method=Image.Quantize.FASTOCTREE,
)
quantized.save("output.png", "PNG", optimize=True)

On the 1200×671 test image, 977 KB became 101 KB (10.3%). Dropping to 128 or 64 colours changed almost nothing, so 256 is the sensible starting point.

Quantisation only works well on images with few colours. Logos, icons, diagrams and screenshots survive it with no visible loss. Photographs have far too many colours:

▼How image type affects quantisation

Image type Result at 256 colours
Diagram or screenshot 10.3% (no visible loss)
Photograph 50.2% (banding in gradients)

Photographs should not be quantised — they should be JPEG. The same photograph saved at JPEG quality 70 came to 112 KB, against 479 KB for the quantised PNG. Not using PNG for photographs that do not need transparency is the single biggest win available.

Using pngquant

For higher-quality quantisation, call pngquant from Python with subprocess.

pngquant is built on the libimagequant library and allocates palette colours more carefully than Pillow’s FASTOCTREE. It preserves transparency.

On macOS, install it with Homebrew:

brew install pngquant
import subprocess

subprocess.run([
    "pngquant",
    "--quality=65-85",   # skip the file if it cannot hit this range
    "--force",           # overwrite an existing output
    "--output", "output.png",
    "input.png",
])

--quality takes a floor and a ceiling. Files that cannot meet the floor are skipped rather than degraded, which guards against silently ruining an image.

If your Pillow build includes libimagequant, you get the same quality without the external binary:

from PIL import features
print(features.check("libimagequant"))

If that prints True, pass Image.Quantize.LIBIMAGEQUANT as the method. Pillow installed through pip often reports False, in which case install pngquant separately or stay with FASTOCTREE.

Squeezing out the last few percent

For PNG, quantising and then running a lossless optimiser gives the best result.

  1. pngquant reduces to 256 colours (lossy; this is where the bulk of the saving happens)
  2. oxipng recompresses the DEFLATE stream (lossless; the finishing pass)

The second pass adds a few percent at most. Implement the first, measure, and decide whether the second is worth the extra dependency.

Depending on the use case, converting to WebP beats compressing the PNG at all. WebP supports both lossy and lossless modes and handles transparency. With Pillow it is a matter of changing the extension:

img.save("output.webp", "WEBP", quality=80)

Browser support stopped being a concern some time ago, so it belongs on the list for anything web-facing.

How to decide

Before tuning compression levels, check that the format itself is right. Picking the wrong format costs far more than any quality setting.

▼Format by image type

Image type Format Approach
Photograph JPEG or WebP Quality 70 to 90
Logo, icon, diagram PNG Quantise to 256 colours
Photograph needing transparency WebP Lossy with alpha
Animation WebP Far smaller than GIF

Writing out several levels at once, as this app does, lets you compare them side by side and pick a ratio from what you can actually see rather than from the numbers alone.

If you would rather ship a tool people can use without installing Python, a browser-based version is an option — a web app I built called Pokémon Palette covers that route.