見出し画像

【Pythonプログラム】複数の画像をグリッド上に並べるツール


はじめに

この記事では、指定したフォルダに存在している複数の画像ファイルを任意のサイズのグリッド上(2行3列、4行4列など)に並べていくプログラムのご紹介です。
カタログファイル的な使用や大きなディスプレイでの観賞用、多くの画像ファイルを効率的に見渡す・・・などの用途に使用できると思います。

動作環境について

このプログラムはPythonで記述しています。(Python 3.9.10)
また、Windows環境で動作します。Macで動作させるためにはパス名などの記述を調整する必要があると思いますが、こちらにMacの環境がないので動作保証外とさせていただきます。

それから、事前に外部のライブラリを2つインストールしておく必要があります。「Pillowとnatsort」です。
これらは、画像ファイルを編集するものと、ファイル名などの文字列を自然な形でソートしてくれるものです。
「img1.png, img2.png, img10.png, img11.png」のような配列を普通に辞書順でソートすると、「img2.png」が「img10.png」などの後ろに回ってしまいますが、natsortライブラリを使用すると、きちんと数字の順番にソートしてくれます。

pip install Pillow
pip install natsort

必要な前提知識

記事の後半でソースコードの全文(grid_img.py:約150行)を表示します。
読者の方はPythonに関する基本的な知識が必要になります。「Google Colab」などのツールを使用することも可能かもしれませんが、画像ファイルのアップロードや作成したグリッドファイルのダウンロード作業などが面倒だと思うので、ローカル環境にPythonをインストールしておくことを強くお勧めします。
提供されたPythonファイルをご自身の環境で実行することができれば大丈夫だと思います。ソースコードはいくつかの機能をカスタマイズできるようにしてあります。対象とする画像ファイルの拡張子やグリッド上に並べる時に、最後に余った端数部分をどうするか、、、など。

プログラムの使い方

はじめに、グリッド上に並べたい画像ファイルを任意のフォルダに保存しておきます。ここでは、「C:\Users\nsnhr\Pictures\GridTest」というフォルダに画像ファイルを20枚コピーしてあります。これは、以前の記事で紹介したStable Diffusion WebUIで作成した画像になります。

秀丸ファイラーClassicの画面
画像ファイルのプレビュー(IrfanView64のサムネイル表示)

それでは、プログラムの使い方を説明します。
VSCodeやVisual Studioなどで「grid_img.py」を実行すると、下記のような質問が表示されます。

ここで、フォルダのフルパス名とグリッドのサイズを入力します。どちらも半角の英数字で入力してください。

ここでは「3列2行」(横に3つ、縦に2つ)を指定しました。
普通は「2行3列」のように行から先に指定すると思うのですが、グリッドを指定する際には、横方向の数を先に指定する方が自然な感じがしたので、列、行の順で入力するようにしています。
正常に処理が完了すると下記のように表示されます。

元ファイルがあるフォルダの中に「output_grid」というサブフォルダを作成して、その中にグリッドファイルが保存されます。

grid_img_01.jpg
grid_img_02.jpg
grid_img_03.jpg
grid_img_04.jpg

「grid_img_04.jpg」のみ画像サイズが異なっていますが、これは端数処理が行われた結果です。6枚ごとにグリッド画像を作成するので、最後に2枚余ってしまいます。これを6枚用のグリッドに並べると、下の1行が完全に空白になってしまいます。
デフォルトでは、行全体が空白になった場合には画像サイズを切り詰めます。また、先頭の1行についても右側の1つ分が空白になってしまうので、ここも同様に切り詰めます。その結果、最後の1枚のみ(1行2列)の画像となります。

ただし、この最適化処理はオフにすることも可能です。その場合には、最後の画像は以下のようになります。ちょっと分かりにくいですが、下側と右側に余白があります。

grid_img_04.jpg

ちなみに、「5列4行」を指定すると以下のようなグリッド画像が作成されます。この場合は1枚のグリッド画像で20枚分をすべて収めることができます。

grid_img_01.jpg

注意事項

グリッド作成元の各画像ファイルはすべて同じサイズであることが必要です。サイズが異なると、グリッドファイルが凸凹になってしまい見苦しいのでこのような仕様にしてあります。

また、このプログラムは実行するたびに「output_grid」フォルダの中身を初期化するようにしています。
正確には「output_grid」フォルダが存在する場合には、いったん中身も含めて削除してから同名のフォルダを作成しています。作成したグリッドファイルは必要に応じて他の場所に移動(コピー)してください。

なお、作成するグリッドファイルはJPG形式(quality=95)のファイルになります。もし、PNG形式やPDF形式などが希望の場合にはソースコードのコメント部分を参考にしてください。すべてのグリッドファイルを1つのPDFファイルに収めるコードも紹介しています。

プログラムのソースコード

それでは、以下にプログラムのソースコードを掲載します。約150行くらいです。
ソースコードの後にファイル(grid_img.py)のダウンロード用のリンクがあります。

import glob
import os
import shutil
import sys

from natsort import natsorted
from PIL import Image, UnidentifiedImageError


def input_info():
    pathname = input('画像ファイルが保存されているフォルダのフルパス名を入力してください:\n')

    if pathname[-1] == '\\':
        pathname = pathname[:-1]

    if not os.path.exists(pathname):
        raise ValueError('指定されたフォルダが見つかりません。')
    
    grid_dim = input('1枚のグリッドに並べる画像の列数(X)と行数(Y)を入力してください(例 3, 2):\n')
    grid_info = grid_dim.split(',')
    dim = []

    for item in grid_info:
        item = item.strip()

        if item.isdecimal():
            dim.append(int(item))
        else:
            raise ValueError('グリッドの列数(X)と行数(Y)が読み込めません。\n半角の数値で指定してください。')

    if len(dim) != 2:
        raise ValueError('グリッドの書式が正しくありません')

    return pathname, dim


def grid_img(pathname, dim, opt_size=True):
    col, row = dim
    grid_num = col * row

    # 出力フォルダの作成(既に存在する場合にはフォルダごと中身を削除する)
    target_path = pathname + '\\output_grid'

    if os.path.exists(target_path):
        shutil.rmtree(target_path)

    os.makedirs(target_path, exist_ok=True)

    files = []
    ext_list = ('png', 'jpg', 'gif') # 対象拡張子

    for ext in ext_list:
        files += glob.glob(pathname + f'\\*.{ext}') 

    # ファイル名の自然な番号順に並べ替える
    files = natsorted(files)
    
    width = height = 0
    index = 1

    while len(files) > 0:
        #1枚のグリッドに並べる分の画像を取り出す
        unit = files[:grid_num]
        del files[:grid_num]

        items = [None] * len(unit)

        for i, file in enumerate(unit):
            try:
                items[i] = Image.open(file)
            except UnidentifiedImageError as e:
                print(f'エラーが発生しました:{e}')
                return False
            
            # 各画像のサイズをチェック
            if width <= 0:
               width = items[i].width
            elif width != items[i].width:
                print('個々の画像のサイズが異なっています')
                return False

            if height <= 0:
               height = items[i].height
            elif height != items[i].height:
                print('個々の画像のサイズが異なっています')
                return False

        # グリッドに余白ができる場合の処理(必要なら画像サイズを変更する)
        grid_x, grid_y = col, row
        items_num = len(items)

        if opt_size and (items_num < grid_num):
            q, r = divmod(items_num, col)

            if r > 0:
                if q == 0:
                    grid_x, grid_y = r, 1
                else:
                    grid_x, grid_y = col, q + 1
            else:
                grid_x, grid_y = col, q

        # グリッド用のファイルを作成
        grid_img = Image.new('RGB', (width * grid_x, height * grid_y), (255, 255, 255))

        # グリッドに各画像を並べていく
        try:
            for r in range(grid_y):
                for c in range(grid_x):
                    grid_img.paste(items[r * grid_x + c], (width * c, height * r))
        except IndexError:
            pass

        # 出力ファイルの名前を設定(例 grid_img_01.jpg)
        # (JPG形式は保存時間も短く、品質を上げてもファイルサイズはかなり小さくなります)
        filename = f'grid_img_{index:02d}.jpg'
        grid_img.save(target_path + '\\' + filename, "JPEG", quality=95)

        # 出力ファイルの名前を設定(例 grid_img_01.png)
        # (PNG形式は保存に時間がかかり、ファイルサイズもかなり大きくなります)
        #filename = f'grid_img_{index:02d}.png'
        #grid_img.save(target_path + '\\' + filename, "PNG", compress_level=4)

        # 出力ファイルの名前を設定(例 grid_img_01.pdf)
        # (PDF形式は保存時間も短く、ファイルサイズもかなり小さくなります)
        #filename = f'grid_img_{index:02d}.pdf'
        #grid_img.save(target_path + '\\' + filename, "PDF")

        # 出力ファイルの名前を設定(例 grid_img.pdf)
        # (こちらの方法は1つのPDFファイルにすべての画像を追加します)
        #filename = f'grid_img.pdf'
        #append = False if index == 1 else True
        #grid_img.save(target_path + '\\' + filename, "PDF", append=append)

        index += 1

        # GIF形式の画像の場合などはclose()が必要になる(らしい)
        for item in items:
            if item: item.close()

    print(f'以下のフォルダにグリッドファイルを保存しました。\n==> {target_path}')
    return True


def main():
    try:
        pathname, dim = input_info()
    except ValueError as e:
        print(f'エラーが発生しました:{e}')
        return

    grid_img(pathname, dim, True)


if __name__ == '__main__':
    main()

ソースコードの解説

このプログラムは全部で3つの関数から構成されています。

  • main()

  • input_info()

  • grid_img()

main関数の説明

def main():
    try:
        pathname, dim = input_info()
    except ValueError as e:
        print(f'エラーが発生しました:{e}')
        return

    grid_img(pathname, dim, True)

main関数ではinput_info関数とgrid_img関数を順番に呼び出しています。
「画像の作成に必要な情報を取得する関数」と「実際にグリッドに画像を並べる関数」です。
input_info関数でエラーが起きた場合には、例外をスローしてプログラムを中断します。
入力エラーがなかった場合のみ、次の処理に進みます。

input_info関数の説明

def input_info():
    pathname = input('画像ファイルが保存されているフォルダのフルパス名を入力してください:\n')

    if pathname[-1] == '\\':
        pathname = pathname[:-1]

    if not os.path.exists(pathname):
        raise ValueError('指定されたフォルダが見つかりません。')
    
    grid_dim = input('1枚のグリッドに並べる画像の列数(X)と行数(Y)を入力してください(例 3, 2):\n')
    grid_info = grid_dim.split(',')
    dim = []

    for item in grid_info:
        item = item.strip()

        if item.isdecimal():
            dim.append(int(item))
        else:
            raise ValueError('グリッドの列数(X)と行数(Y)が読み込めません。\n半角の数値で指定してください。')

    if len(dim) != 2:
        raise ValueError('グリッドの書式が正しくありません')

    return pathname, dim

input_info関数では、グリッドの作成元の画像ファイルを格納しているフォルダのフルパス名を入力してもらいます。後で出力用のパス名を作成する場合のことを考えて、末尾が「\」で終わっている場合にはここで削除しておきます。
そして、指定されたフォルダが実際に存在しているかを確認しています。

次に、グリッドのサイズ(行数と列数)を入力してもらいます。私の個人的な感覚で、横方向の個数を始めに入力するのが自然な気がするので、列数(X軸の方向)、行数(Y軸の方向)の順番で入力してもらいます。もし、違和感がある場合には入れ替えてください。

グリッドの指定は「3, 2」のような文字列で取得されますので、ここで数値に変換してリストに格納しています。
エラーが起きた場合には例外をスローします。

grid_img関数の説明

def grid_img(pathname, dim, opt_size=True):
    col, row = dim
    grid_num = col * row

    # 出力フォルダの作成(既に存在する場合にはフォルダごと中身を削除する)
    target_path = pathname + '\\output_grid'

    if os.path.exists(target_path):
        shutil.rmtree(target_path)

    os.makedirs(target_path, exist_ok=True)

    files = []
    ext_list = ('png', 'jpg', 'gif') # 対象拡張子

    for ext in ext_list:
        files += glob.glob(pathname + f'\\*.{ext}') 

    # ファイル名の自然な番号順に並べ替える
    files = natsorted(files)
    
    width = height = 0
    index = 1

    while len(files) > 0:
        #1枚のグリッドに並べる分の画像を取り出す
        unit = files[:grid_num]
        del files[:grid_num]

        items = [None] * len(unit)

        for i, file in enumerate(unit):
            try:
                items[i] = Image.open(file)
            except UnidentifiedImageError as e:
                print(f'エラーが発生しました:{e}')
                return False
            
            # 各画像のサイズをチェック
            if width <= 0:
               width = items[i].width
            elif width != items[i].width:
                print('個々の画像のサイズが異なっています')
                return False

            if height <= 0:
               height = items[i].height
            elif height != items[i].height:
                print('個々の画像のサイズが異なっています')
                return False

        # グリッドに余白ができる場合の処理(必要なら画像サイズを変更する)
        grid_x, grid_y = col, row
        items_num = len(items)

        if opt_size and (items_num < grid_num):
            q, r = divmod(items_num, col)

            if r > 0:
                if q == 0:
                    grid_x, grid_y = r, 1
                else:
                    grid_x, grid_y = col, q + 1
            else:
                grid_x, grid_y = col, q

        # グリッド用のファイルを作成
        grid_img = Image.new('RGB', (width * grid_x, height * grid_y), (255, 255, 255))

        # グリッドに各画像を並べていく
        try:
            for r in range(grid_y):
                for c in range(grid_x):
                    grid_img.paste(items[r * grid_x + c], (width * c, height * r))
        except IndexError:
            pass

        # 出力ファイルの名前を設定(例 grid_img_01.jpg)
        # (JPG形式は保存時間も短く、品質を上げてもファイルサイズはかなり小さくなります)
        filename = f'grid_img_{index:02d}.jpg'
        grid_img.save(target_path + '\\' + filename, "JPEG", quality=95)

        # 出力ファイルの名前を設定(例 grid_img_01.png)
        # (PNG形式は保存に時間がかかり、ファイルサイズもかなり大きくなります)
        #filename = f'grid_img_{index:02d}.png'
        #grid_img.save(target_path + '\\' + filename, "PNG", compress_level=4)

        # 出力ファイルの名前を設定(例 grid_img_01.pdf)
        # (PDF形式は保存時間も短く、ファイルサイズもかなり小さくなります)
        #filename = f'grid_img_{index:02d}.pdf'
        #grid_img.save(target_path + '\\' + filename, "PDF")

        # 出力ファイルの名前を設定(例 grid_img.pdf)
        # (こちらの方法は1つのPDFファイルにすべての画像を追加します)
        #filename = f'grid_img.pdf'
        #append = False if index == 1 else True
        #grid_img.save(target_path + '\\' + filename, "PDF", append=append)

        index += 1

        # GIF形式の画像の場合などはclose()が必要になる(らしい)
        for item in items:
            if item: item.close()

    print(f'以下のフォルダにグリッドファイルを保存しました。\n==> {target_path}')
    return True

この関数が今回のプログラムのメインの部分です。

まず、引数についてですが、pathnameは個々の画像ファイルが格納されているフォルダのフルパス名、dimはグリッドのサイズ(列数と行数)、opt_sizeはグリッドに余白が残った場合の処理です。最適化を行って画像を切り詰める場合にはTrue、切り詰めない場合にはFalseを指定します。

次に出力フォルダの初期化を行います。output_gridという名前のフォルダがなければ作成し、既に存在すれば中身をすべて削除します。

そして、glob.glob()を使用して対象となる画像ファイルのフルパス名をすべて取得します。拡張子ごとにループ処理をしてリストに追加していきます。このプログラムでは「png, jpg, gif」の3種類を取得していますが、他の画像ファイルも対象にしたい場合には追加してください。

その後、natsorted関数で画像ファイルの番号順にソートします。
グリッドに並べる時には、このリストの順番で行います。

最後に、while文ですべての画像ファイルをグリッドに追加するまで処理を行います。

仮に(3列2行)のグリッドの場合、ソート済のfiles配列から6個ずつファイルを取り出してグリッド用の画像に並べていきます。この時、各画像のサイズが同じであることを確認します。
そして、取り出したファイルが6個未満になった場合には、最後の端数処理を行います。この部分の処理は少しトリッキーな感じになってしまいました。

q, r = divmod(items_num, col) # 3列2行の場合は「col=3」です

仮に4個の画像が端数として残ったとしら、上記のコードによってそれを3列に収めるために必要な行数と余りを計算しています。この場合は、「q=1, r=1」となりますので、1行目が埋まって2行目に画像が1つだけはみ出すことになります。つまり、2行分の高さが必要になります。この時、グリッドの高さ(行数)が3よりも大きい場合には、「q+1」(つまり2)に切り詰めます。
もし、「q=0」の場合は1行のみの画像になり、この場合に限り列方向も切り詰める処理を行います。
文章だとちょっと分かりずらいかもしれませんが、デバッガなどでステップ実行してみてください。

作成したグリッド画像の名前は「grid_img_01.jpg」のような書式にしています。もし作成する画像の枚数が100枚を超えるようなら、
「filename = f'grid_img_{index:03d}.jpg'」のように3桁にしてください。

また、保存する画像ファイルの形式はデフォルトではJPGにしています。PNG形式とPDF形式で保存するコードもコメントとして記述してありますので、そちらの形式で保存したい場合にはコメントを解除してください。ただし、保存にかかる時間やファイルサイズなどを考慮するとJPG形式が一番パフォーマンスがいいように思います。PNG形式はかなり時間がかかります。PDF形式の場合には、すべての画像を1つのPDFファイルに収めることもできます。

おわりに

Stable Diffusion WebUIを使用してバッチカウントを4とか6に設定していると、作成した画像を自動的にグリッド画像として保存/表示してくれます。ただし、ここには手指が破綻してしまったボツ画像なども多く含まれているため、必要な画像のみをまとめてグリッド画像として作成できたら便利だなぁ、と思って今回のプログラムを作成してみました。

今後もいろいろと役に立つプログラムを作成したいと思いますので、こんなプログラムが欲しいというようなことがありましたら、是非お聞かせください。

最後まで読んで頂きましてありがとうございました。

この記事が気に入ったらサポートをしてみませんか?