💻

PNG画像の別の色パターンを楽に作るツールを作ったので紹介

2024/12/17に公開

はじめに

どうも、でんのこです。
今回はPNG画像を読み込み、彩度と色相の異なるパターンの画像を自動で作ってくれるツールをPythonで作成したので軽く紹介しようと思います。

動機

最近3Dモデルのテクスチャに手を加える機会が多く、単純な彩度や色相の変換なら自動でやってくれるプログラムを書いたほうが楽だろうな〜と思ったため制作しました。

実行環境

microsoftの提供するPython 3のDockerImageをベースとしてVscodeのDev Containerで動作するように環境ごと制作しました。
https://hub.docker.com/r/microsoft/devcontainers-python

Dockerを使用せずともPythonが実行できる環境かつPillowライブラリをインストールしていれば動作すると思います。※未確認です

機能

できること

彩度、色相をそれぞれ何通り作るか指定し、それらの全ての組み合わせでPNG画像を生成することが可能です。

彩度を3パターン、色相を10パターンと指定すると、彩度33%, 66%, 99%(だいたい)と色相を36度(360度÷10)刻みで変換した10パターンの組み合わせの30パターンの画像が生成されます。

元画像
少し粗い画像ですが実際に使用した例です。
300px
彩度2/3
300px
色相変換
300px
300px

できないこと

彩度や色相の細かい指定には対応していません。
細かく調整したい場合は生成するパターンを増やしてちょうどいいものを探すことである程度は対応できます。

使い方

フォルダ構成はGitHubのリポジトリを参考にしてください。
もしDockerを使用されない場合は、main.pyとinputフォルダ、outputフォルダが同一ディレクトリにあれば動く...はず...(未確認)

デフォルト設定で使用する場合

  1. inputフォルダに元となるPNG画像を入れる(複数枚でもOK)
  2. ターミナルで python main.py を実行
  3. outputフォルダ内にinputした画像ファイル名のフォルダが生成され、その中に生成された画像が入る

※inputフォルダに入れた画像は、処理が終了した後にoutputフォルダに移動されます

設定をカスタマイズする場合

  1. inputフォルダに元となるPNG画像を入れる(複数枚でもOK)
  2. ターミナルで python main.py --hue_steps (色相の数) --saturation_steps (彩度の数)というようにそれぞれの値を指定して実行

    python main.py --hue_steps 15 --saturation_steps 5
    この例では色相15段階、彩度5段階の75パターンの画像が生成されます。
  3. outputフォルダ内にinputした画像ファイル名のフォルダが生成され、その中に生成された画像が入る

※inputフォルダに入れた画像は、処理が終了した後にoutputフォルダに移動されます

実装

GitHubリポジトリ

DevContainerの環境ごと入っているリポジトリです。
こちらを使用してVSCodeのDev Containerコンテナをビルドすれば使用できる状態になると思います。
https://github.com/dennoko/PNG-color-pattern-generator

プログラム

色変換

プログラムの中核となる画像変換部分のコードです。
関数全体を示した後、重要な処理についてそれぞれ軽く説明します。

def adjust_image_color(image_path, hue_steps=10, saturation_steps=3):
    """
    指定された色相と彩度の段階数に基づいて画像の色バリエーションを生成
    
    :param image_path: 元画像のパス
    :param hue_steps: 色相の段階数
    :param saturation_steps: 彩度の段階数
    :return: 生成された画像のパスのリスト
    """
    # 画像を開く
    img = Image.open(image_path).convert('RGBA')
    
    # 画像データを取得
    data = img.getdata()
    
    generated_images = []
    
    # 色相と彩度のステップを計算
    hue_increment = 360 / hue_steps
    saturation_increment = 1.0 / saturation_steps
    
    for hue_step in range(hue_steps):
        for sat_step in range(saturation_steps):
            # 新しい画像データリストを作成
            new_data = []
            
            # 色相と彩度の値を計算
            current_hue = (hue_step * hue_increment) / 360.0
            current_sat = (sat_step + 1) * saturation_increment
            
            for item in data:
                # アルファチャンネルは変更しない
                if item[3] == 0:
                    new_data.append(item)
                    continue
                
                # RGBをHSVに変換
                r, g, b = item[:3]
                h, s, v = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0)
                
                # 色相と彩度を調整
                h = (h + current_hue) % 1.0
                s = min(s * current_sat, 1.0)
                
                # HSVをRGBに戻す
                r, g, b = colorsys.hsv_to_rgb(h, s, v)
                
                # 新しい色データを追加
                new_data.append((
                    int(r * 255), 
                    int(g * 255), 
                    int(b * 255), 
                    item[3]
                ))
            
            # 新しい画像を作成
            new_img = Image.new('RGBA', img.size)
            new_img.putdata(new_data)
            
            # 出力パスを生成
            filename = os.path.basename(image_path)
            name, ext = os.path.splitext(filename)
            output_dir = os.path.join('output', name)
            os.makedirs(output_dir, exist_ok=True)
            
            output_path = os.path.join(output_dir, f"{name}_hue{hue_step}_sat{sat_step}.png")
            new_img.save(output_path)
            generated_images.append(output_path)
    
    return generated_images

色相と彩度の差の大きさを計算

    # 色相と彩度のステップを計算
    hue_increment = 360 / hue_steps
    saturation_increment = 1.0 / saturation_steps

色相は360度を作りたいパターン数で割った値を基準とします。
彩度は1.0を作りたいパターン数で割った値を基準とします。

色相と彩度の計算

current_hue = (hue_step * hue_increment) / 360.0
current_sat = (sat_step + 1) * saturation_increment
  • current_hue: 色相の回転角度を計算

    • hue_stepとhue_incrementを使って、色相環上で均等に分散した角度を生成
    • 0.0〜1.0の範囲に正規化(360度を1.0として)
  • current_sat: 彩度の倍率を計算

    • sat_stepを使って、徐々に彩度を増加させる
    • 例えば、3段階の場合、1/3、2/3、3/3の彩度レベルを作成

透明なピクセルをスキップ

for item in data:
    # アルファチャンネルは変更しない
    if item[3] == 0:
        new_data.append(item)
        continue

item[3]が0、つまり完全に透明なピクセルはcontinueによってスキップします。

RGB→HSV変換

r, g, b = item[:3]
h, s, v = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0)

色相を扱いやすくするためにRGB形式のデータをHSVに変換します。

色相と彩度の調整

h = (h + current_hue) % 1.0
s = min(s * current_sat, 1.0)
  • h: 元の色相にcurrent_hueを加算し、色相環上で回転

    • % 1.0で、1.0(360度)を超えたら最初に戻る
  • s: 元の彩度にcurrent_satを乗算

    • min()で、彩度が1.0を超えないように制限

HSV→RGBに戻し新しいデータとして追加

r, g, b = colorsys.hsv_to_rgb(h, s, v)

new_data.append((
    int(r * 255), 
    int(g * 255), 
    int(b * 255), 
    item[3]
))

新しいRGBの値をnew_dataに追加します。
アルファの値は変更しません。

新しい画像の生成

new_img = Image.new('RGBA', img.size)

全てのピクセルに対して変換操作が終わったら、そのデータを元に新しい画像を生成

全体

最後に全体のコードを載せておきます。

import os
import shutil
from PIL import Image
import colorsys
import argparse

def adjust_image_color(image_path, hue_steps=10, saturation_steps=3):
    """
    指定された色相と彩度の段階数に基づいて画像の色バリエーションを生成
    
    :param image_path: 元画像のパス
    :param hue_steps: 色相の段階数
    :param saturation_steps: 彩度の段階数
    :return: 生成された画像のパスのリスト
    """
    # 画像を開く
    img = Image.open(image_path).convert('RGBA')
    
    # 画像データを取得
    data = img.getdata()
    
    generated_images = []
    
    # 色相と彩度のステップを計算
    hue_increment = 360 / hue_steps
    saturation_increment = 1.0 / saturation_steps
    
    for hue_step in range(hue_steps):
        for sat_step in range(saturation_steps):
            # 新しい画像データリストを作成
            new_data = []
            
            # 色相と彩度の値を計算
            current_hue = (hue_step * hue_increment) / 360.0
            current_sat = (sat_step + 1) * saturation_increment
            
            for item in data:
                # アルファチャンネルは変更しない
                if item[3] == 0:
                    new_data.append(item)
                    continue
                
                # RGBをHSVに変換
                r, g, b = item[:3]
                h, s, v = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0)
                
                # 色相と彩度を調整
                h = (h + current_hue) % 1.0
                s = min(s * current_sat, 1.0)
                
                # HSVをRGBに戻す
                r, g, b = colorsys.hsv_to_rgb(h, s, v)
                
                # 新しい色データを追加
                new_data.append((
                    int(r * 255), 
                    int(g * 255), 
                    int(b * 255), 
                    item[3]
                ))
            
            # 新しい画像を作成
            new_img = Image.new('RGBA', img.size)
            new_img.putdata(new_data)
            
            # 出力パスを生成
            filename = os.path.basename(image_path)
            name, ext = os.path.splitext(filename)
            output_dir = os.path.join('output', name)
            os.makedirs(output_dir, exist_ok=True)
            
            output_path = os.path.join(output_dir, f"{name}_hue{hue_step}_sat{sat_step}.png")
            new_img.save(output_path)
            generated_images.append(output_path)
    
    return generated_images

def process_input_folder(input_folder='input', hue_steps=10, saturation_steps=3):
    """
    入力フォルダ内のすべてのPNG画像を処理
    
    :param input_folder: 入力画像が格納されているフォルダ
    :param hue_steps: 色相の段階数
    :param saturation_steps: 彩度の段階数
    """
    # 出力フォルダが存在しない場合は作成
    os.makedirs('output', exist_ok=True)
    
    # 入力フォルダ内のすべてのPNG画像を処理
    for filename in os.listdir(input_folder):
        if filename.lower().endswith('.png'):
            image_path = os.path.join(input_folder, filename)
            
            # 画像の色バリエーションを生成
            generated_images = adjust_image_color(
                image_path, 
                hue_steps=hue_steps, 
                saturation_steps=saturation_steps
            )
            
            # 元の画像を出力フォルダにコピー
            name, _ = os.path.splitext(filename)
            output_dir = os.path.join('output', name)
            os.makedirs(output_dir, exist_ok=True)
            shutil.copy2(image_path, os.path.join(output_dir, filename))
            
            # 処理が完了したら入力フォルダから削除
            os.remove(image_path)
    
    print("画像の処理が完了しました。")

def main():
    """
    コマンドラインから引数を受け取り、画像処理を実行
    """
    parser = argparse.ArgumentParser(description='PNG画像の色相と彩度を変更')
    parser.add_argument('--hue_steps', type=int, default=10, 
                        help='色相の段階数 (デフォルト: 10)')
    parser.add_argument('--saturation_steps', type=int, default=3, 
                        help='彩度の段階数 (デフォルト: 3)')
    
    args = parser.parse_args()
    
    process_input_folder(
        hue_steps=args.hue_steps, 
        saturation_steps=args.saturation_steps
    )

if __name__ == '__main__':
    main()

Discussion