PNGを圧縮して高速化。標準入力に渡して圧縮するコード例【Python, pngquant】

スポンサーリンク

高速表示とサイズ削減のために、PNGを減色して圧縮する方法です。ピンクォワント(pngquant.exe)の標準入力(stdin)に画像データを渡して圧縮します。

普通にファイルパスを指定して圧縮する方法は「PNGを圧縮して高速化【Python, pngquant】」で紹介しています。pngquant.exe の入手方法や圧縮画像の品質比較も載っています。

それで標準入力を使用する理由なのですが、pngquant はどうも日本語を含むファイルパスを解釈できないようなんですね。以下のようなエラーメッセージが表示されます。

"D:\project\data_plot\日本語.png"
error: cannot open D:\project\data_plot\譌・譛ャ隱・png for reading

pngquant の中で文字化けしてしまって、存在しないファイルパスになっています。

そこで、ファイルの読み込みと保存はパイソンに任せて、pngquant には画像の圧縮だけをしてもらうことにします。pngquant には標準入力からデータを受け取る機能がありましたので、その機能を使用してパイソンコードを書きます。圧縮画像は、標準出力からバイナリデータとして受け取ることができます。

以下、標準入出力でPNG画像を圧縮するコード例です。

モジュール群のインポート

exeファイルを使用するので、subprocess関連をインポートします。

また、標準エラー出力のバイナリデータをデコードするために、ロケールモジュール(locale)もインポートします。標準入出力でバイナリデータを扱う関係上、標準エラー出力もバイナリデータになるので、これでシステムのエンコードを推定してデコードに利用します。

マットプロットリブ(matplotlib)は、テスト画像を作るためにインポートしています。

import os
from subprocess import Popen
from subprocess import PIPE
from subprocess import TimeoutExpired
import locale # locale.getpreferredencoding(False)で使用
import traceback

# テスト画像生成用のマットプロットリブ
from matplotlib import pyplot as plt

PNG圧縮関数 (pngquant.exe)

pngquant のコマンドライン文字列を生成して、実行する関数です。オプションの説明は pngquant に同梱されている「README.txt」に載っています。

入力ファイルパスを指定する部分にハイフン'-'だけを指定すると、標準入力(stdin)からデータを読み込んで、結果を標準出力(stdout)に返してくれるようになります。この標準出力は、p.communicate() から標準エラー出力(stderr)と一緒に受け取ります。

標準入力にはバイナリデータを渡します。ここでテキストデータを渡してしまうと、 pngquant.exe が延々と入力待ちのような状態になってしまいます。p.communicate()のタイムアウトも発生しないようです。

ほかのオプションですが、基本的には色数(ncolors)の設定だけで良いと思います。これが一番見た目とサイズに影響するオプションです。画像にもよりますが、ncolors=256(色)なら、ほぼ同じ見た目で半分以下のサイズになりました。

その他の speed、quality、strip などは、指定しなくても大丈夫です。デフォルトの状態でも、特に問題なく圧縮してくれました。

def compress_image_stdio(
    exe_file, image_data, ncolors,
    speed=None, quality=None, strip=None,
    timeout=30):
    """標準入力にデータを渡して、標準出力から結果を受け取る。"""

    # スピードオプション
    if speed is None:
        speed_op = ''
    else:
        speed_op = ' --speed {speed}'.format(speed=speed)

    # クオリティオプション
    if quality is None:
        quality_op = ''
    else:
        quality_op = ' --quality {min}-{max}'.format(
            min=quality[0],
            max=quality[1],
            )

    # ストリップオプション
    if strip is True:
        strip_op = ' --strip'
    else:
        strip_op = ''

    # 色数オプション
    ncolors_op = ' {ncolors}'.format(ncolors=ncolors)

    # データを標準入力から受け取って標準出力で返すオプション
    std_io_op = ' -'

    # コマンドライン文字列生成
    cmd = '"{exe}"{quality}{speed}{strip}{ncolors}{std_io}'.format(
        exe=exe_file,
        quality=quality_op,
        speed=speed_op,
        strip=strip_op,
        ncolors=ncolors_op,
        std_io=std_io_op,
        )

    # 子プロセス実行
    with Popen(
        cmd,
        stdin=PIPE, stdout=PIPE, stderr=PIPE,
        shell=False) as p:
        try:
            # 標準入力にバイナリデータを渡して、子プロセスの終了を待つ。
            (bin_outs, bin_errs) = p.communicate(
                                    input=image_data, timeout=timeout)
        except TimeoutExpired:
            # 子プロセスを終了
            p.kill()

            # 通信を再試行
            (bin_outs, bin_errs) = p.communicate(timeout=timeout)
    return (cmd, p.returncode, bin_outs, bin_errs)

コマンドライン文字列の例です。入出力のファイルパスが無いので短いです。

"D:\\project\\tool\\pngquant\\pngquant.exe" --quality 0-100 --strip 64 -

メイン関数

テスト画像を保存して、それをパイソンで開き、pngquant で圧縮して、パイソンで保存するコード例です。

画像は開く時も保存する時もバイナリモードです。pngquant に渡すデータも受け取るデータもバイナリデータです。ファイルパスをパイソン側で処理しているので、パスに日本語を含む画像でも圧縮することができます。

def main():
    """テスト"""
    # コマンドラインツールのファイルパス
    pngquant_exe = r'D:\project\tool\pngquant\pngquant.exe'

    # データフォルダ
    data_dir = r'D:\project\data_plot'

    # 入力・出力ファイル
    filename = "000.png"
    in_file = os.path.join(data_dir, filename)
    out_file = os.path.join(
        data_dir, '%s_compressed%s' % os.path.splitext(filename))

    # 子プロセスのタイムアウト(秒)
    timeout = 30

    # システムのエンコーディングを取得
    preferred_encoding = locale.getpreferredencoding(False)

    # 作図して保存
    (fig, ax) = plt.subplots(ncols=1, nrows=1)
    try:
        # テスト画像生成
        x  = [ 1,  2,  3,  4]
        y1 = [ 3, 9,  27, 81]
        y2 = [10, 15, 20, 25]
        y3 = [15, 30, 45, 60]
        y4 = [20, 50, 65, 70]
        ax.set_xlim(0, 5)
        ax.set_ylim(0, 100)
        ax.plot(x, y1, label='y1', lw=3, alpha=0.6, marker='o', ms=8)
        ax.plot(x, y2, label='y2', lw=3, alpha=0.6, marker='o', ms=8)
        ax.plot(x, y3, label='y3', lw=3, alpha=0.6, marker='o', ms=8)
        ax.plot(x, y4, label='y4', lw=3, alpha=0.6, marker='o', ms=8)
        ax.grid()
        ax.legend(loc=2, fancybox=True)
        fig.set_dpi(100)
        fig.set_figwidth(3.6)
        fig.set_figheight(3.6)
        fig.savefig(in_file)
    finally:
        plt.close()

    # Pythonで画像をバイナリモードで読み込む
    with open(in_file, 'rb') as fr:
        image_data = fr.read()

    # pngquant.exe で圧縮
    (cmd, returncode, bin_outs, bin_errs) = compress_image_stdio(
        pngquant_exe,
        image_data,
        ncolors=64,
        speed=None,
        quality=(0, 100),
        strip=True,
        timeout=timeout,
        )

    # エラーがあれば表示
    if returncode != 0:
        print('%s\n%s\n%s' % (cmd, returncode, bin_errs.decode(preferred_encoding)))
    elif bin_errs:
        print('%s\n%s\n%s' % (cmd, returncode, bin_errs.decode(preferred_encoding)))

    if bin_outs:
        # Pythonで画像を保存
        with open(out_file, 'wb') as fw:
            fw.write(bin_outs)
    return

何らかのエラーで圧縮に失敗した場合は、標準出力から空のバイナリデータ(b'')が返ってきます。空のファイルを作成しても仕方ないので、if文でスキップしています。空のバイナリデータが False と判定されることを利用しています。

ところで、エラーが発生した時の pngquant の標準エラー出力ですが、バイナリデータになっているので、表示するにはデコードする必要があります。問題は何のエンコーディングを指定するかですね。

公式マニュアル(Python 3.5)によると、locale.getpreferredencoding(False) を利用している場合があるようだったので、それを使うことにしました。

よく使われる引数 subprocess – サブプロセス管理
https://docs.python.jp/3.5/library/subprocess.html?highlight=popen#frequently-used-arguments

戻り値はPythonの実行環境に依存するようですが、私の環境では 'cp932' が返ってきました(Windows 7 64bit、Python 3.5.2 64bit)。 以下の説明によると、この値は単なる推測によるものとのことでしたが、最初に試すエンコーディングとしては良いのかもしれません。

locale.getpreferredencoding(do_setlocale=True) locale – 国際化サービス
https://docs.python.jp/3.5/library/locale.html#locale.getpreferredencoding

pngquant のエラーメッセージの例

標準入力に渡したPNGデータが壊れていたときのエラーの例です。

リターンコードは25でした。

25
error: IDAT: invalid distances set (libpng failed)
error: cannot decode image from stdin
タイトルとURLをコピーしました