Python:Pillowでサムネイルの作成に失敗することがある

自宅サーバで運用してる Python で作った webアプリがあって、Pillow で画像のサムネイルを作るようになってるんだけど、ときどきサムネイルの作成に失敗していることがあるのに気がついた。

この webアプリを作ったときにどう考えたのか忘れたけど、もとの画像が png ならサムネイルも png、もとの画像が jpg ならサムネイルも jpg になる。失敗してるのは png のサムネイルを作るとき(の一部)のようだ。サムネイルをつくるのには Pillow の Image.thumbnail() 関数を使ってる。

で、検証のためのスクリプトを書いた。Python と Pillow のバージョンは次の通り。

  • Python 3.10.8
  • Pillow 9.5.0
from PIL import Image
from os import path
import argparse


THUMBNAIL_SIZE = (240, 240)


def main():
    args = parse_arguments()
    orig_file = args.orig
    im = Image.open(orig_file)
    im.thumbnail(THUMBNAIL_SIZE)

    (name, _) = path.splitext(orig_file)
    thumb_file = f'thumb_{name}.{args.format}'
    im.save(thumb_file)
    print(f'Thumbnail has maked successfully: {thumb_file}')


def parse_arguments():
    parser = argparse.ArgumentParser(
        description='Make thumbnail from original image.'
    )
    parser.add_argument(
        'orig',
        action='store',
        help='original image'
    )
    parser.add_argument(
        '-f', '--format',
        action='store',
        default='jpg',
        help='specify thumbnail file format (`jpg` to default)'
    )
    args = parser.parse_args()

    return args


main()

使い方は簡単。もと画像のファイルを引数にして実行すればサムネイルが作られる。--format オプションでサムネイルのフォーマットを指定できる(デフォルトは jpg)。

takatoh@sofa: Documents > python make_thumbnail.py --help
usage: make_thumbnail.py [-h] [-f FORMAT] orig

Make thumbnail from original image.

positional arguments:
  orig                  original image

options:
  -h, --help            show this help message and exit
  -f FORMAT, --format FORMAT
                        specify thumbnail file format (default to `jpg`)

さて、このスクリプトで、用意した png 画像のサムネイルを作ってみる。フォーマットに jpg を指定したときは期待通りにサムネイルが作成される。

takatoh@sofa: Documents > python make_thumbnail.py --format jpg sample1.png
Thumbnail has maked successfully: thumb_sample1.jpg

ところが、フォーマットに png を指定するとエラーになる。

takatoh@sofa: Documents > python make_thumbnail.py --format png sample1.png
Traceback (most recent call last):
  File "C:\Users\takatoh\Documents\make_thumbnail.py", line 42, in <module>
    main()
  File "C:\Users\takatoh\Documents\make_thumbnail.py", line 17, in main
    im.save(thumb_file)
  File "C:\Users\takatoh\AppData\Local\Programs\Python\Python310\lib\site-packages\PIL\Image.py", line 2432, in save
    save_handler(self, fp, filename)
  File "C:\Users\takatoh\AppData\Local\Programs\Python\Python310\lib\site-packages\PIL\PngImagePlugin.py", line 1318, in _save
    data = name + b"\0\0" + zlib.compress(icc)
TypeError: a bytes-like object is required, not 'str'

png のときはいつもエラーになるってわけでもない。下の例のように、ちゃんとサムネイルが作成されることもある。

takatoh@sofa: Documents > python make_thumbnail.py --format png sample2.png
Thumbnail has maked successfully: thumb_sample2.png

どうも、もとの png 画像によってエラーになることがあるらしい。多分まれなケース。ヒントはエラーメッセージに出ている。TypeError だ。bytes-like object でなければならないところで str が来ている。Pillow の PIL/PngImagePlugin.py ファイルの1318行目でエラーになってる。ソースファイルからこの付近を抜き出してみよう。

    icc = im.encoderinfo.get("icc_profile", im.info.get("icc_profile"))
    if icc:
        # ICC profile
        # according to PNG spec, the iCCP chunk contains:
        # Profile name  1-79 bytes (character string)
        # Null separator        1 byte (null character)
        # Compression method    1 byte (0)
        # Compressed profile    n bytes (zlib with deflate compression)
        name = b"ICC Profile"
        data = name + b"\0\0" + zlib.compress(icc)
        chunk(fp, b"iCCP", data)

これは _save() 関数の一部で、下から2行目がファイルの1318行目にあたる。バイト文字列を連結して data 変数に代入してる。name 変数は上の行でバイト文字列のリテラルを代入してるし、b"\0\0" もバイト文字列のリテラルだ。てことは zlib.compress(icc) が怪しい。ちょっと話を端折るけど、icc 変数には im.info.get("icc_profile") で取得した値が入ってる。im は Pillow の Image オブジェクト。

そこで、手を動かしてこれを追いかけてみる。

takatoh@sofa: Documents > python
Python 3.10.8 (tags/v3.10.8:aaaf517, Oct 11 2022, 16:50:30) [MSC v.1933 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from PIL import Image
>>> import zlib
>>> im = Image.open('sample1.png')
>>> icc = im.info.get("icc_profile")
>>> name = b"ICC Profile"
>>> data = name + b"\0\0" + zlib.compress(icc)
Traceback (most recent call last):
  File "", line 1, in 
TypeError: a bytes-like object is required, not 'str'

サムネイルを作るときと同じエラーが出た。もう少し粒度を細かくしてみよう。zlib.compress(icc) のとこだけ。

>>> icc_compressed = zlib.compress(icc)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: a bytes-like object is required, not 'str'

ああ、つまり iccbytes-like object じゃなくて str なわけだ。

>>> type(icc)
<class 'str'>

やっぱり。

エラーにならなかった sample2.png ファイルでも試してみよう。

>>> im2 = Image.open('sample2.png')
>>> icc2 = im2.info.get("icc_profile")
>>> type(icc2)
<class 'bytes'>

なるほど、ファイルによって im.info.get("icc_profile") で得られる値が bytes だったり str だったりするらしい。で、str だと zlib.compress() でエラーになる、と。

これは Pillow のバグなのか?それとも不適切な png ファイルのせいなのか?

これ以上追いかけるのはちょっと手に余るな。