pythonでモザイクアート作ってみた

画像をもとにモザイクアートを生成するPythonスクリプトを作ってみました。
小さな画像素材を使って、指定した画像をモザイク状に再構成します。
Pyhon初学者なので粗っぽいところは温かい目で見ていただけますと...


### 🧱 完成イメージ
モザイク前
![angel-luciano-LATYeZyw88c-unsplash (1).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572617/b01aaa54-6655-4f5f-9bc6-8e772b288bc9.jpeg)

モザイク後
![mosaic.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572617/c5f8a4a5-2925-42e7-8942-9dbf515aadd5.jpeg)


---

### 🔧 使用技術・ライブラリ

* Python 3.x
* Pillow
* NumPy
* ファイル構成:

  ```
    .
    ├── images/
    │   ├── base.jpg          # モザイクに変換するベース画像
    │   └── tiles/            # 小さな素材画像（タイルとして使う）
    ├── output/
    │   ├── tiles_final/      # タイルを合成して保存した画像群
    │   ├── tiles_preview/    # プレビュー用に加工した素材画像
    │   ├── mosaic.jpg        # 最終的に生成されたモザイクアート
    │   └── mapping.json      # 各タイルの配置情報
    └── main.py               # コード本体
  ```


### 実装の流れ

* 元画像を読み込み、指定サイズ（例：10×10px）のグリッドに分割する。
* 各ブロックの平均色（RGB）を計算する。
* tiles/ ディレクトリ内の素材画像すべての平均色を算出する。
* 各ブロックに対して、平均色が近い画像を10枚選出し、その中からランダムで1枚を選択する。
* 選出画像の上に、元ブロックの平均色を20%の透明度で重ねて合成（画像として出力）。

ブロックに対して、平均色が近い画像を10枚選出しているのは特定のカラーコードのます全てに同じ画像を使用しないようにするためです。

また、全体を見た時に違和感がないように上にカラーレイヤーを載せています。ここの透明度は調整可能です。

### コード全体


```python
import random
from PIL import Image
import numpy as np
import os
import glob
import json

INPUT_IMAGE_PATH = "images/base.jpg"
TILE_SIZE = 5
TILES_DIR = "images/tiles"
OUTPUT_DIR = "output/tiles_final"
MAPPING_PATH = "output/mapping.json"


def get_average_color(image):
    img = image.convert("RGB").resize((TILE_SIZE, TILE_SIZE))
    arr = np.array(img)
    return tuple(arr.mean(axis=(0, 1)))


def color_distance(c1, c2):
    return np.sqrt(sum((a - b) ** 2 for a, b in zip(c1, c2)))


def load_tile_data():
    tile_paths = glob.glob(os.path.join(TILES_DIR, "*"))
    tile_data = []
    for path in tile_paths:
        try:
            img = Image.open(path)
            avg = get_average_color(img)
            tile_data.append({"path": path, "avg_color": avg})
        except Exception as e:
            print(f"スキップ: {path} → {e}")
    return tile_data


def find_closest_tiles(avg_color, tile_data, top_n=10):
    scored = [
        (color_distance(avg_color, tile["avg_color"]), tile["path"])
        for tile in tile_data
    ]
    scored.sort(key=lambda x: x[0])
    return [path for _, path in scored[:top_n]]


def blend_overlay(image, overlay_color, opacity=0.5):
    overlay = Image.new("RGB", image.size, tuple(map(int, overlay_color)))
    return Image.blend(image.convert("RGB"), overlay, opacity)


tile_data = load_tile_data()
base_image = Image.open(INPUT_IMAGE_PATH).convert("RGB")
width, height = base_image.size
print(f"画像サイズ: {width}x{height}")

mapping = []
index = 0


for y in range(0, height, TILE_SIZE):
    print(f"{y / TILE_SIZE} / {height / TILE_SIZE} 行目処理中...")
    for x in range(0, width, TILE_SIZE):
        tile = base_image.crop((x, y, x + TILE_SIZE, y + TILE_SIZE))
        avg_color = get_average_color(tile)

        candidates = find_closest_tiles(avg_color, tile_data)
        selected_path = random.choice(candidates)

        selected_image = Image.open(
            selected_path).resize((TILE_SIZE, TILE_SIZE))
        blended = blend_overlay(selected_image, avg_color)

        output_name = f"tile_{index:04d}.jpg"
        output_path = os.path.join(OUTPUT_DIR, output_name)
        blended.save(output_path)

        mapping.append({
            "x": x,
            "y": y,
            "w": TILE_SIZE,
            "h": TILE_SIZE,
            "src": f"tiles_final/{output_name}",
            "original": os.path.basename(selected_path)
        })

        index += 1

        os.makedirs(os.path.dirname(MAPPING_PATH), exist_ok=True)

with open(MAPPING_PATH, "w", encoding="utf-8") as f:
    json.dump(mapping, f, indent=2, ensure_ascii=False)

print(f"✅ mapping.json を {MAPPING_PATH} に出力しました")

print("🧱 mosaic.jpg を作成中...")

mosaic_image = Image.new("RGB", (width, height))
with open("output/mapping.json", "r", encoding="utf-8") as f:
    mapping = json.load(f)

for tile in mapping:
    tile_path = os.path.join("output", tile["src"])
    print(f"{tile_path} を ({tile['x']}, {tile['y']}) に貼り付け")
    try:
        tile_img = Image.open(tile_path).convert("RGB")
        mosaic_image.paste(tile_img, (tile["x"], tile["y"]))
    except Exception as e:
        print(f"⚠️ 貼り付け失敗: {tile_path} → {e}")

mosaic_image.save("output/mosaic.jpg")
print("✅ mosaic.jpg を出力しました")


print("🖼️ プレビュー画像を出力中...")

PREVIEW_DIR = "output/tiles_preview"
PREVIEW_SIZE = 400
os.makedirs(PREVIEW_DIR, exist_ok=True)

for i, tile in enumerate(mapping[:10]):
    try:
        original_path = os.path.join("images/tiles", tile["original"])
        img = Image.open(original_path).convert("RGB")

        avg_color = get_average_color(img)

        resized = img.resize((PREVIEW_SIZE, PREVIEW_SIZE), Image.LANCZOS)
        blended = blend_overlay(resized, avg_color)

        preview_path = os.path.join(PREVIEW_DIR, f"preview_{i:04d}.jpg")
        blended.save(preview_path)
        print(f"✅ {preview_path} を出力しました")

    except Exception as e:
        print(f"⚠️ {i} 枚目のプレビュー作成失敗: {e}")

```



### ざっくり解説

各関数の役割をまとめました。

### `get_average_color(image)`

画像全体の平均色（RGB）を計算して返します。
モザイク化する各領域の「代表的な色」を知るために使います。

---

### `color_distance(c1, c2)`

2つのRGBカラーの色差をユークリッド距離で計算します。
「どの素材タイルが似ているか」を判断するための距離です。

---

### `load_tile_data()`

`images/tiles/` フォルダにある素材画像をすべて読み込み、平均色を計算します。
各画像のパスと平均色をセットにしてリスト化します。

---

### `find_closest_tiles(avg_color, tile_data, top_n=10)`

指定した平均色に一番近い素材画像を最大 `top_n` 件まで選びます。
この中からランダムで1枚選び、モザイクの一部として使います。

---

### `blend_overlay(image, overlay_color, opacity=0.5)`

指定した画像に、色付きの半透明レイヤーを重ねて合成します。
タイル画像の色味をベース画像に少し近づける目的で使用しています。

---

### メイン処理

* ベース画像を `TILE_SIZE` ごとの小さなブロックに分割
* 各ブロックの平均色に近い素材画像を選び、合成
* 合成画像を `output/tiles_final/` に保存し、位置情報を `mapping.json` に記録

---

### モザイク画像の生成

保存された `mapping.json` をもとに、全タイルを1枚の画像に貼り付けて
`output/mosaic.jpg` として保存します。

---

### プレビュー画像の作成

最初の10枚だけ、素材画像を大きめにリサイズ＆色重ねして
`output/tiles_preview/` に確認用として出力します。

この後ズームしてマスの画像も細かく確認できるようにしたいのでこの処理を加えています
ただ生成には時間がかかるのでここでは10枚のみとしています