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.
- pngquant reduces to 256 colours (lossy; this is where the bulk of the saving happens)
- 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.