トップページ -> Pythonでモザイクアートを作る

Pythonでモザイクアートを作る

今回はPythonでモザイクアートを作ってみました.

  1. 1. モザイクアートを作る際の工夫
  2. 2. 素材画像を集める
  3. 3. 素材画像の下処理
  4. 4. 実際のコード
  5. 5. 実際に出来上がった画像

【関連】モザイクアートメーカー
今回紹介するPythonとほぼ同じことがブラウザで試せます.クライアントサイドで処理が完結するため画像をアップロードする必要はありませんが, デバイスのスペックによっては必要タイル数が多くなると時間がかかります.ある程度のスペックのデバイスでないと厳しいかもしれません. 動的KDTreeを使っているためモザイクアートの作成自体は高速ですが,画像の読み込みが遅いです.

1. モザイクアートを作る際の工夫

モザイクアートを綺麗に作るために以下のような工夫をしました.

(1) 1枚の素材を最大で何回使うかを指定できるようにする

素材画像の枚数が少ない場合はすべての画像を1回使うだけでは綺麗に元画像を再現することができません. しかし,一番色の近い素材画像を無制限に使うようにすると同じ画像が何度も選ばれ単調になります. 例えば,人物の画像のモザイクアートを作るときに髪の毛が1枚の夜空の画像で埋め尽くされてしまうようなことが起きます. これを防ぐために素材を最大で何回使うかを指定できるようにしました.

(2) タイルを配置する順番をランダムにする

素材を使う回数に制限をかけた場合,左上から右下に向かって順にタイルを配置していくと左上は豊富な素材の中から綺麗に再現できるが,右下に行くにつれて余り物から選び取らなければならず, 綺麗に再現できなくなってしまうようなことが起こってしまいます. これを防ぐためにランダムな順番でタイルを配置するようにしています.(タイルの使用回数に制限を書けない場合はランダムでも同じものが出来上がります)

(3) 色の補正を行う

どうしても再現できない色を綺麗に再現するために許容値の範囲内で色補正を行うオプションを組み込みました. 0~255で色補正を許します.0は色補正を行わず,255は平均色を全く同じにする補正を施します. 許容値が大きすぎると元の色味が失われます.

(4) ランダムに素材を選ぶ

モザイクアートが単調にならないようにランダムに素材を配置する割合を設定できるようにしました. 人が緑になってしまったりはしますが,100%ランダムで選択するようにしても色補正の許容値を255にすると元の画像が再現できます.

(5) KDTreeを使用して高速化する

仕上がりには影響がありませんが,max_usage=Noneのときはkd木を使って処理を高速化しました. 動的KD-Treeが使えるならmax_usage分使った要素を削除していったらmax_usageを指定する場合でも早いかもしれません.

(6) 色差式を使う

scipyではカスタム距離関数を指定したKD-Treeはサポートされていないそうなのですが,RGB座標のユークリッド距離で色差を求めるよりも CIEDE2000などの色差式を使った方が人物の顔の輪郭や陰の表現が上手くなる気がします(?)
【参考(外部サイト)】色差 - Wikipedia
【参考(外部サイト)】新しい色差式(CIE DE2000)について|色色雑学

2. 素材画像を集める

モザイクアートを作るためには素材となる画像がたくさん必要になります. すでに1つのフォルダにまとまったたくさんの画像データを持っている方はそれらを利用することができます. 素材画像が手元にない もしくはわざわざPCに移動するのが面倒な場合は以下のサイトで素材画像をダウンロードすることができます. 【関連(外部サイト)】Flickr 8k Dataset - Kaggle 8091枚のパブリックドメイン画像データセットがダウンロード可能です.後述のメトロポリタン美術館のパブリック・ドメイン画像データセットより色のバリエーションが多いです.
また,メトロポリタン美術館のパブリック・ドメイン絵画画像をPythonで収集するで紹介されている方法でパブリックドメインの画像データセットをダウンロードすることができます. こちらは20万枚程度のパブリックドメイン画像データが利用できます.
以下ではFlickr 8k Datasetを素材画像として秋の草木と見る本栖湖 - 写真ACを作ります.

秋の草木と見る本栖湖 - 写真AC
秋の草木と見る本栖湖 - 写真AC

3. 素材画像の下処理

素材画像を必要な大きさにトリミングします. 本当は人間の目で重要な部分をトリミングすべきですが,今回は一括で画像の中心から正方形を切り出して,リサイズするようにしています. ここではOriginalImagesフォルダの中の画像を32×32に素材をリサイズしmaterialsに保存します.


from PIL import Image
import os

# 元画像を中央から正方形にトリミングし、指定サイズにリサイズする。
def crop_and_resize(image, target_size):
    width, height = image.size
    # 最小辺を基準に正方形を作る
    min_dim = min(width, height)
    left = (width - min_dim) // 2
    top = (height - min_dim) // 2
    right = (width + min_dim) // 2
    bottom = (height + min_dim) // 2
    cropped_image = image.crop((left, top, right, bottom))
    return cropped_image.resize(target_size, Image.ANTIALIAS)

# 入力フォルダと出力フォルダのパス
input_folder = "OriginalImages"  # 元画像フォルダ
output_folder = "materials"  # 出力先フォルダ
target_size = (32, 32)  # 最終的なリサイズサイズ(例: 32x32ピクセル)

# 出力フォルダを作成
os.makedirs(output_folder, exist_ok=True)

# 画像を処理
for filename in os.listdir(input_folder):
    file_path = os.path.join(input_folder, filename)
    try:
        with Image.open(file_path) as img:
            # トリミング&リサイズ
            resized_img = crop_and_resize(img, target_size)
            # 出力フォルダに保存
            resized_img.save(os.path.join(output_folder, filename))
            print(f"Processed: {filename}")
    except Exception as e:
        print(f"Error processing {filename}: {e}")

4. 実際のコード

materialsフォルダの中の素材画像を使用してtest.jpgを作ります. 以下では単純にRGB座標間のユークリッド距離が小さくなるようにタイルを選んでいますが, list(np.argsort(np.linalg.norm(avg_colors - region_color, axis=1))) を書き換えることでCIEDE2000などにもできます.


import random 
from PIL import Image
import os
import numpy as np
from tqdm import tqdm # 進捗バーの表示
from scipy.spatial import KDTree # kd木による素材画像選択の高速化
from collections import defaultdict

# 画像の平均色を計算する(画像を1点につぶして計算する)
def calculate_average_color(image):
    image = image.resize((1, 1))
    return np.array(image.getpixel((0, 0)), dtype=np.float32)

# 素材画像を読み込み画像と対応する平均色のリストを作成する
def load_material_images(folder_path):
    material_images = [] # 素材画像のリスト
    avg_colors = [] # 平均色のリスト
    for filename in os.listdir(folder_path):
        file_path = os.path.join(folder_path, filename)
        try:
            img = Image.open(file_path).convert("RGB")
            avg_color = calculate_average_color(img)
            material_images.append(img)
            avg_colors.append(avg_color)
        except:
            print(f"Failed to process image: {file_path}")
    return material_images, np.array(avg_colors)

# 色を許容値内で目標色に近づける
def apply_color_correction(original_color, target_color, tolerance):
    adjustment = np.clip(target_color - original_color, -tolerance, tolerance)
    corrected_color = original_color + adjustment
    corrected_color = np.clip(corrected_color, 0, 255)  # RGBの範囲に制限
    return corrected_color

def create_mosaic(base_image_path, material_images_folder, tile_size, output_path, tolerance=10, max_usage=None, random_percentage=0.1):
    # ベース画像の読み込み
    base_image = Image.open(base_image_path).convert("RGB")
    base_image = base_image.resize((base_image.width // tile_size, base_image.height // tile_size))
    base_colors = np.array([[base_image.getpixel((x, y)) for x in range(base_image.width)] for y in range(base_image.height)])
    
    # 素材画像の読み込み
    material_images, avg_colors = load_material_images(material_images_folder)
    kdtree = KDTree(avg_colors)  # KDTreeの構築
    
    # 素材画像の使用回数を記録
    usage_counts = defaultdict(int)
    
    # 空のモザイク画像を作成
    mosaic = Image.new("RGB", (base_image.width * tile_size, base_image.height * tile_size))
    
    # タイル座標リストを作成してランダムにシャッフル
    tile_coords = [(x, y) for y in range(base_image.height) for x in range(base_image.width)]
    random.shuffle(tile_coords)  # 座標をランダムにシャッフル

    # ランダムで配置するタイルの割合に基づき、処理の一部をランダムに変更
    random_count = int(len(tile_coords) * random_percentage)  # ランダムに配置するタイルの数
    random_tiles = random.sample(tile_coords, random_count)  # ランダムに選んだタイル

    # タイルごとの進捗バー
    total_tiles = base_image.width * base_image.height
    with tqdm(total=total_tiles, desc="Processing tiles", unit="tile") as pbar:
        for x, y in tile_coords:  # 順番に処理
            region_color = base_colors[y, x]
            
            # ランダムに配置するタイルなら、ランダムに素材を選択
            if (x, y) in random_tiles:
                idx = random.choice(range(len(material_images)))  # ランダムに素材画像を選択
            else:
                # KDTreeを使用して最も近い素材画像を検索
                if max_usage == None:
                    _, idx = kdtree.query(region_color)
                
                # max_usageの制限を考慮して素材画像を選択
                if max_usage is not None:
                    candidates = list(np.argsort(np.linalg.norm(avg_colors - region_color, axis=1)))
                    for candidate_idx in candidates:
                        if usage_counts[candidate_idx] %lt; max_usage:
                            idx = candidate_idx
                            break
            
            # 素材画像を取得し、使用回数を増加
            closest_img = material_images[idx]
            usage_counts[idx] += 1
            
            # 元画像領域と素材画像の色補正
            corrected_color = apply_color_correction(avg_colors[idx], region_color, tolerance)
            corrected_tile = closest_img.resize((tile_size, tile_size))
            
            # タイル画像の補正を適用
            tile_array = np.array(corrected_tile, dtype=np.float32)
            correction_factor = corrected_color / avg_colors[idx]
            corrected_tile_array = tile_array * correction_factor
            corrected_tile_array = np.clip(corrected_tile_array, 0, 255).astype(np.uint8)
            corrected_tile_image = Image.fromarray(corrected_tile_array)
            
            # 補正後のタイルを配置
            mosaic.paste(corrected_tile_image, (x * tile_size, y * tile_size))
            pbar.update(1)
    
    mosaic.save(output_path)
    print(f"モザイクアートを保存しました: {output_path}")

# 実行例
base_image_path = "test.jpg"
material_images_folder = "materials"
tile_size = 32 # タイルサイズ
tolerance = 30 # 色補正の許容値(0~255 大きすぎると元の色味が失われます)
max_usage = 5 # 1枚の素材を最大で何回使うか(大きすぎると同じ画像ばかり使われる)
random_percentage = 0.05  # 5%のタイルをランダムに配置
output_path = f"mosaic_art_size{tile_size}_randomOrder_{int(random_percentage*100)}percent_tolerance{tolerance}_maxusage{max_usage}.jpg"

create_mosaic(base_image_path, material_images_folder, tile_size, output_path, tolerance=tolerance, max_usage=max_usage, random_percentage=random_percentage)

5. 実際に出来上がった画像

秋の草木と見る本栖湖 - 写真ACを6400×4820にリサイズしてベース画像としました.

(1) タイルサイズ32, max_usage=None
タイルサイズ32, max_usage=Noneのモザイクアート
タイルサイズ32で素材画像を3万枚使っています. 使用制限がないため同じ画像が何度も使われてしまっていますが,画像自体は綺麗に表現できます. 別のタブで画像を開いて拡大してみるとタイルサイズが小さいため素材画像が若干見づらいです.
(2) タイルサイズ32, max_usage=4
タイルサイズ32, max_usage=4のモザイクアート
使用制限をかけ1枚の画像を4回までの使用にとどめました. 色が足りない部分がありますが,タイルサイズが小さいのでエッジはうまく表現できている気がします.
(3) タイルサイズ64, max_usage=4
タイルサイズ64, max_usage=4のモザイクアート
タイルサイズ64で素材画像を7500枚使っています. タイルサイズが大きくなったことで使用制限が相対的に弱まったため色味は(2)より上手く出ている気がします. エッジの表現は微妙な感じです. タイルサイズが大きくなったのでスマホであれば拡大すれば素材画像が何かが認識できるギリギリの小ささだと思います.
(4) タイルサイズ64, max_usage=4, tolerance=20
タイルサイズ64, max_usage=4, tolerance=20のモザイクアート
色補正を許容値20でかけたものです. 全体の色味はよくなりましたが,素材画像を拡大してみると若干不自然になっています. 素材画像の色味を損なってもいい場合は素材の色に偏りがあってもうまく表現できるかもしれません. 山頂のような色差が少ないエッジは表現が難しいです.