Python と wxPython で、ドラッグ&ドロップした画像を圧縮する GUI アプリを作ります。jpg と png の両方に対応させます。
結論から書くと、jpg は OpenCV の imencode で品質を指定して圧縮できます。png は OpenCV でも圧縮されますが可逆圧縮なので12%ほどしか小さくなりません。png を大きく減らすには減色(非可逆)が必要です。
この記事はもともと「png の圧縮方法が分からない」という状態で公開した備忘録でした。その後 png 側の答えが出たので、実測値とコードを追記して書き直しています。
▼1200×671のPNG(977KB)で実測した結果
| 方法 | 圧縮後 | 元比 |
|---|---|---|
可逆圧縮 compress_level=9 |
863KB | 88.3% |
可逆圧縮 optimize=True |
862KB | 88.2% |
| 減色 256色 | 101KB | 10.3% |
可逆圧縮では12%しか減らないのに対し、減色すると約90%減りました。png の圧縮で効くのは圧縮レベルではなく減色です。
スポンサーリンク
作るアプリの仕様
結論:ウインドウにファイルをドラッグ&ドロップすると、複数の圧縮率で一括保存する GUI アプリです。圧縮率ごとの見比べができます。
- ドロップエリアに画像をまとめて投げ込める
- jpg は品質90〜10の7段階、png は圧縮率の6段階で保存する
- 圧縮率ごとにフォルダを分けて出力する
- 処理結果をウインドウ内のログに表示する
GUI には wxPython(wx)、画像処理には OpenCV(cv2)を使います。
起動前にインストールするもの
結論:wxPython と OpenCV を pip で入れます。
pip install wxPython opencv-python
後半で使う Pillow も入れておくと、png の減色まで試せます。
pip install Pillow
起動はターミナルから実行します。
python ファイル名.py
スポンサーリンク
ソースコード全文
# -*- 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()
コードの構成
結論:クラスは2つです。ドロップを受け取る FileDropTarget と、ウインドウと圧縮処理を持つ App に分かれています。
▼処理の流れ
| メソッド | 役割 |
|---|---|
OnDropFiles |
ドロップされたファイルのパスを受け取る |
pathList |
パスを1件ずつ回し、拡張子で処理するか判定する |
imgComp |
拡張子に応じて jpg / png の圧縮を実行する |
imgWrite |
圧縮結果をフォルダに分けて保存する |
fileName |
パスからファイル名を取り出す |
ドロップされたパスは OnDropFiles から pathList へ渡り、拡張子が jpg か png のものだけが imgComp に流れます。それ以外はログに「圧縮できない」と表示して終わります。
スポンサーリンク
jpgの圧縮方法
結論:OpenCV の imencode に IMWRITE_JPEG_QUALITY を渡して品質を指定します。jpg は非可逆なので、品質を下げるほど確実に小さくなります。
result, encimg = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
decimg = cv2.imdecode(encimg, -1)
imencode はファイルに書き出さず、メモリ上で圧縮した結果を返します。それを imdecode で画像に戻してから保存しているので、圧縮の前後を同じ処理で扱えます。
品質の指定は0〜100で、大きいほど高品質かつ大きいファイルになります。1024×768の写真で実測した結果が次のとおりです。
▼JPEG品質とファイルサイズ(1024×768の写真)
| 品質 | サイズ | 用途の目安 |
|---|---|---|
| 90 | 221KB | 劣化をほぼ感じない |
| 70 | 112KB | Web掲載の実用ライン |
| 50 | 81KB | 拡大すると粗が見える |
| 10 | 28KB | 明らかにブロックノイズが出る |
品質90から70に下げるとサイズは半分になりますが、見た目の差はほとんどありません。一方で50から10まで下げてもサイズは3分の1にしかならず、劣化だけが目立ちます。下げる価値があるのは90〜70のあたりだと分かります。
pngの圧縮方法
結論:OpenCV の IMWRITE_PNG_COMPRESSION は動きますが、png は可逆圧縮の形式なので効果が小さいだけです。「圧縮できない」のではなく「あまり小さくならない」が正確なところです。
IMWRITE_PNG_COMPRESSION は0〜9で指定します。この数値が変えているのは DEFLATE(zip と同じ可逆圧縮)の探索の深さで、画質は一切変わりません。そのため減るのは十数パーセントが限界です。
result, encimg = cv2.imencode(".png", img, [int(cv2.IMWRITE_PNG_COMPRESSION), 9])
実測では、977KB の png が compress_level=9 で 863KB(88.3%)にしかなりませんでした。png のファイルサイズを大きく落としたいなら、次の減色を使います。
減色(非可逆)で大きく減らす
結論:フルカラーの png を256色以下のパレット形式に変換すると、ファイルサイズが1割程度まで落ちます。Pillow の quantize() なら外部ツールなしでできます。
from PIL import Image
img = Image.open("input.png")
# RGBA を扱うので FASTOCTREE を指定する
# MEDIANCUT と MAXCOVERAGE は RGBA 非対応
quantized = img.convert("RGBA").quantize(
colors=256,
method=Image.Quantize.FASTOCTREE,
)
quantized.save("output.png", "PNG", optimize=True)
1200×671 の png で実測したところ、977KB が 101KB(10.3%)になりました。色数を128色や64色まで落としても結果はほとんど変わらなかったので、まず256色で試すのがおすすめです。
ただし、減色が効くのは色数の少ない画像に限ります。ロゴ・アイコン・図版・スクリーンショットのような平坦な色の画像では劣化がほぼ分かりません。一方で写真は色数が多いため、同じ処理でも次のようになりました。
▼画像の種類による減色の効き方
| 画像の種類 | 256色に減色した結果 |
|---|---|
| 図版・スクリーンショット | 10.3%(劣化はほぼ分からない) |
| 写真 | 50.2%(グラデーションに縞が出る) |
写真は減色せず、そもそも jpg で保存するほうが小さくなります。同じ写真を JPEG 品質70で保存すると112KB で、減色した png の479KB より大幅に小さくなりました。透過が不要な写真は png にしないのが最も効く判断です。
pngquantを使う場合
結論:より高品質な減色をしたいなら pngquant を使います。Python からは subprocess で呼び出します。
pngquant は libimagequant というライブラリを使った減色ツールで、Pillow の FASTOCTREE より色の割り当てが丁寧です。透過も保持されます。
macOS なら Homebrew で入ります。
brew install pngquant
Python からの呼び出しはこう書きます。
import subprocess
subprocess.run([
"pngquant",
"--quality=65-85", # この品質に収まらなければ変換しない
"--force", # 出力先が既にあれば上書きする
"--output", "output.png",
"input.png",
])
--quality は下限と上限を指定します。下限を下回る品質にしかできない画像は変換されずスキップされるので、劣化しすぎる事故を防げます。
Pillow が libimagequant 付きでビルドされている場合は、外部コマンドなしで同じ品質が使えます。次のコードで確認できます。
from PIL import features
print(features.check("libimagequant"))
True が返るなら、quantize() の method に Image.Quantize.LIBIMAGEQUANT を指定できます。pip で入れた Pillow では False になることが多く、その場合は pngquant を別途入れるか FASTOCTREE を使うことになります。
さらに小さくしたい場合
結論:減色したあとに可逆圧縮ツールをもう一度かける2段構えが、png では最も効きます。
pngquant で減色してから oxipng などの可逆圧縮ツールに通すと、減色だけの場合よりさらに数パーセント落ちます。手順としては次のようになります。
- pngquant で256色に減色する(非可逆・ここで大半が減る)
- oxipng で DEFLATE を再圧縮する(可逆・仕上げ)
ただし、2段目で減るのは数パーセントです。まず1段目だけ実装して、それで足りるかを見てから判断してください。
用途によっては、png のまま圧縮するより WebP に変換するほうが小さくなります。WebP は可逆・非可逆の両方に対応していて、透過も扱えます。Pillow なら保存時に拡張子を変えるだけです。
img.save("output.webp", "WEBP", quality=80)
対応ブラウザを気にする段階はすでに過ぎているので、Web 用途なら選択肢に入れて構いません。
画像圧縮を実装するときの判断基準
結論:圧縮率を上げる前に、そもそも形式が合っているかを先に確認してください。形式の選び間違いのほうが、圧縮率の調整より影響が大きいです。
▼画像の種類と適した形式
| 画像の種類 | 適した形式 | 圧縮の方針 |
|---|---|---|
| 写真 | jpg または WebP | 品質70〜90で調整する |
| ロゴ・アイコン・図版 | png | 256色に減色する |
| 透過が必要な写真 | WebP | 非可逆+透過で保存する |
| アニメーション | WebP | gif より大幅に小さい |
今回のアプリのように複数の圧縮率でまとめて書き出しておくと、実際に見比べてから採用する率を決められます。数値だけで判断せず、出力を並べて見るのがいちばん確実です。
Python で作ったツールを人に配る形にしたい場合は、GUI ではなく Web アプリにする選択肢もあります。ブラウザだけで完結するツールの作り方はポケモンカラーのアイコンを作成できるWebアプリを作った話にまとめています。
ブログに載せる画像をそもそもどこから用意するかは、ブログに使用可能な著作権フリー画像・動画・イラストサイトを参考にしてみてください。