👌

なぜ「Pixel Snapper」は魔法のようにドット絵を復元できるのか? — 平凡なスクリプトとの決定的違い

に公開

この記事は、Gemini3-previewで書きました。元の平凡なコードは私の自作(MIT)
Gemini3に書かせたpython版も、MITライセンスです。

素晴らしい、Sprite Fusion Pixel Snapperのレポジトリー
https://github.com/Hugo-Dz/spritefusion-pixel-snapper

【技術解説】なぜ「Pixel Snapper」は魔法のようにドット絵を復元できるのか? —— 平凡なスクリプトとの決定的違い

AIで生成したドット絵や、拡大されてぼやけた古いゲームのスクリーンショット。これを「元の鮮明なドット絵」に戻したいと思ったことはありませんか?

多くのプログラマが最初に思いつく方法があります。しかし、その方法は大抵うまくいきません。画像が崩れ、色が濁り、使い物にならないのです。

ところが、spritefusion-pixel-snapper というツールは違います。それはまるで、熟練のドット職人が手作業で打ち直したかのような、完璧な復元を行います。

なぜこれほど結果が違うのでしょうか? 私たちが書きがちな「平凡なコード」と比較することで、Pixel Snapper に隠された圧倒的なアルゴリズムの凄さを紐解いていきましょう。


1. 誰もが通る道:「固定グリッド」の限界

ドット絵を復元しようとした時、99%の人が最初に書くコード(ここでは「平凡なアプローチ」と呼びます)は、次のようなロジックです。

「ドット絵は8x8ピクセルのブロックでできているはずだ。だから、定規で8ピクセルごとに線を引いて、その中の平均色で塗りつぶそう」

Pythonで書けば、こんな感じです(※典型的な例):

# 平凡なアプローチ:機械的にブロック区切り
for y in range(0, h, 8):
    for x in range(0, w, 8):
        # 8ピクセルごとに四角く切り取って、平均色で塗るだけ
        block = img[y:y+8, x:x+8]
        pixel_color = get_average_color(block)
        new_img[y:y+8, x:x+8] = pixel_color

なぜこれでは失敗するのか?

このコードは**「画像が数学的に完璧である」**と信じ込んでいます。しかし、現実は残酷です。

  • 開始位置のズレ: もし画像の左に余白が 3px あったら? 全てのグリッドがズレて、すべてのドットが「隣の色と混ざったゴミ」になります。
  • ドット幅のゆらぎ: 画像が拡大縮小され、1ドットの幅が 8.0px ではなく 8.1px になっていたら? 右に行けば行くほどズレが蓄積し、画像の右半分は崩壊します。

結果として出力されるのは、**「モザイクのかかった汚い画像」**であり、「復元されたドット絵」ではありません。


2. Pixel Snapperの魔法:画像を「見る」アルゴリズム

ここで spritefusion-pixel-snapper の登場です。このツールのアプローチは、根本的に次元が違います。

それは定規を盲目的に当てるのではなく、画像の中身を目で見て、ドットの境界線を探し出します。

① 「Walker」アルゴリズム —— ズレを自動修正する探索者

Pixel Snapper は、固定されたグリッドを使いません。代わりに**「Walker(歩行者)」**と呼ばれるロジックが画像の上を走ります。

  • 平凡なコード: 「次は絶対に8ピクセル先だ」と決めつける。
  • Pixel Snapper: 「だいたい8ピクセル先かな……おっと、8.2ピクセルのところに色の境界線(エッジ)があるぞ。本当の区切りはここだ!」

このように、一歩進むごとに**「一番それらしい境界線」にグリッドをスナップ(吸着)**させます。
画像が多少歪んでいても、開始位置がズレていても、Pixel Snapper はまるで磁石のように「本来あるべきドットの継ぎ目」を正確に捉えます。

② K-Means法 —— 色を「混ぜる」のではなく「選ぶ」

平凡なコードにおけるもう一つの罪は、色の「平均化」です。
ノイズの乗ったブロックの色を混ぜ合わせると、**元画像には存在しなかった「くすんだ中間色」**が生まれてしまいます。

Pixel Snapper はここで、機械学習の手法(K-Meansクラスタリング)を使います。

  1. パレット分析: まず画像全体を見て、「この絵は、実はこの16色だけで構成されているはずだ」と分析します。
  2. ノイズ除去: どんなに微妙なグラデーションや汚れがあっても、その16色のどれかに強制的に分類します。

これにより、グリッドを切る前の段階で、画像は**「完全にクリーンな状態」**に生まれ変わります。だからこそ、出力結果がパキッと鮮やかなのです。


3. 結論:これは「処理」ではなく「復元」だ

平凡なコードと Pixel Snapper の違い。それは、「裁断機」と「考古学者」の違いと言えるでしょう。

  • 平凡なコード: 画像を単なるデータの羅列として扱い、無理やり切り刻む。
  • Pixel Snapper: 画像を「失われた文明(本来のドット絵)」として扱い、ノイズという砂を払い、埋もれていた境界線を丁寧に発掘する。

もしあなたが、AI生成画像や古い素材のドット絵化に悩んでいるなら、自分で for ループを書く前に、この魔法のツールを試すべきです。

spritefusion-pixel-snapper は、単なる画像加工ツールではありません。それは、失われたピクセルへの愛と、高度な数学が融合した**「ドット絵復元エンジン」**なのです。

おまけ Gemini3-previewによるpython版

# under MIT License
# based https://github.com/Hugo-Dz/spritefusion-pixel-snapper generated by gemeni3-preview 
import numpy as np
from PIL import Image
from sklearn.cluster import MiniBatchKMeans
from collections import Counter

def quantize_colors(img, k=16):
    """
    K-Means法を使って画像をk色に減色する(ノイズ除去・パレット整理)
    Rustコードの `quantize_image` に相当
    """
    # RGBAで処理するためにnumpy配列化
    img = img.convert("RGBA")
    w, h = img.size
    data = np.array(img)
    
    # ピクセルを1列に並べる (width * height, 4)
    pixels = data.reshape(-1, 4)
    
    # 透明部分の処理: アルファ値が低いピクセルは計算から除外するか、透明色として扱う
    # ここでは簡易的に、アルファが128以上のピクセルだけで色を学習させる
    valid_pixels = pixels[pixels[:, 3] > 128]
    
    if len(valid_pixels) == 0:
        return img # すべて透明ならそのまま返す

    # K-Meansでクラスタリング (高速化のためMiniBatchを使用)
    kmeans = MiniBatchKMeans(n_clusters=k, batch_size=2048, random_state=42, n_init="auto")
    kmeans.fit(valid_pixels)
    
    # 全ピクセルを最も近い代表色に置き換える
    # 透明ピクセルは (0,0,0,0) に固定
    labels = kmeans.predict(pixels)
    quantized_pixels = kmeans.cluster_centers_[labels].astype(np.uint8)
    
    # 元のアルファ値を考慮して復元
    # 元が透明だった場所は透明に戻す
    is_transparent = pixels[:, 3] < 128
    quantized_pixels[is_transparent] = [0, 0, 0, 0]
    
    quantized_data = quantized_pixels.reshape((h, w, 4))
    return Image.fromarray(quantized_data, "RGBA")

def compute_cuts(profile, length, estimated_block_size):
    """
    プロファイル(エッジの強さ)を見て、最適なカット位置を探す
    Rustコードの `walk` に相当
    """
    cuts = [0]
    current_pos = 0.0
    
    # 探索範囲(前後25%程度を探る)
    search_window = estimated_block_size * 0.25
    limit = length
    
    # プロファイルの平均値を計算(これより弱い山は無視するため)
    mean_val = np.mean(profile) if len(profile) > 0 else 0
    threshold = mean_val * 0.5 # Rustコードの walker_strength_threshold に相当

    while current_pos < limit:
        target = current_pos + estimated_block_size
        
        if target >= limit:
            cuts.append(limit)
            break
            
        # 探索範囲の決定
        start_search = int(max(current_pos + 1, target - search_window))
        end_search = int(min(limit, target + search_window))
        
        if start_search >= end_search:
            current_pos = target
            continue

        # 範囲内で最もエッジが強い場所(山)を探す
        local_region = profile[start_search:end_search]
        if len(local_region) == 0:
            best_idx = int(target)
        else:
            # argmaxは範囲内の相対インデックスを返すので start_search を足す
            best_relative_idx = np.argmax(local_region)
            best_val = local_region[best_relative_idx]
            
            # 山が弱すぎる場合は、等間隔の位置(target)を採用する
            if best_val > threshold:
                best_idx = start_search + best_relative_idx
            else:
                best_idx = int(target)

        # 重複防止と追加
        if best_idx > cuts[-1]:
            cuts.append(best_idx)
            current_pos = best_idx
        else:
            current_pos += 1 # 強制的に進める

    # 最後の位置が画像の端でなければ追加
    if cuts[-1] != limit:
        cuts.append(limit)
        
    return cuts

def get_smart_grid_image(img, block_size=8, k_colors=16):
    """
    メイン処理
    """
    # 1. 減色処理 (K-Means)
    print("Step 1: Quantizing colors...")
    img_quantized = quantize_colors(img, k=k_colors)
    
    # 2. プロファイル作成 (Rustの compute_profiles)
    print("Step 2: Computing profiles...")
    # エッジ検出用にグレースケール化
    gray = np.array(img_quantized.convert("L"), dtype=float)
    
    # 隣接ピクセルとの差分を取る(勾配)
    # axis=1 (横方向の差分) を縦(axis=0)に合計 -> 列ごとのエッジ強度(縦の切れ目)
    col_profile = np.abs(np.diff(gray, axis=1)).sum(axis=0)
    # axis=0 (縦方向の差分) を横(axis=1)に合計 -> 行ごとのエッジ強度(横の切れ目)
    row_profile = np.abs(np.diff(gray, axis=0)).sum(axis=1)
    
    w, h = img_quantized.size
    
    # 3. グリッド位置の決定 (Rustの walk)
    print("Step 3: Finding optimal cuts...")
    col_cuts = compute_cuts(col_profile, w, block_size)
    row_cuts = compute_cuts(row_profile, h, block_size)
    
    # 4. リサンプリング (再構築)
    print(f"Step 4: Resampling ({len(col_cuts)-1}x{len(row_cuts)-1} blocks)...")
    data = np.array(img_quantized)
    new_img = np.zeros_like(data)
    
    # 決定したグリッドごとにループ
    for i in range(len(row_cuts) - 1):
        y_start, y_end = row_cuts[i], row_cuts[i+1]
        
        for j in range(len(col_cuts) - 1):
            x_start, x_end = col_cuts[j], col_cuts[j+1]
            
            # ブロック切り出し
            block = data[y_start:y_end, x_start:x_end]
            
            # ブロック内の色を平坦化
            flat_pixels = block.reshape(-1, 4)
            
            # 透明以外の色を抽出
            valid_pixels = [tuple(p) for p in flat_pixels if p[3] > 0]
            
            if valid_pixels:
                # 最頻値を取得
                rep_color = Counter(valid_pixels).most_common(1)[0][0]
            else:
                rep_color = (0, 0, 0, 0)
            
            # 塗りつぶし
            new_img[y_start:y_end, x_start:x_end] = rep_color

    return Image.fromarray(new_img, "RGBA")

# --- 実行部分 ---
if __name__ == "__main__":
    # 画像パスを指定
    input_path = "knight.webp"  # ここを自分の画像に変えてください
    output_path = "output_smart.png"
    
    try:
        img = Image.open(input_path)
        
        # block_size: 元画像のドットサイズ推定値
        # k_colors: 減色する色数(少ないほどパキッとする)
        result = get_smart_grid_image(img, block_size=8, k_colors=16)
        
        result.save(output_path)
        print(f"Saved to {output_path}")
        
    except FileNotFoundError:
        print(f"Error: {input_path} not found.")
    except Exception as e:
        print(f"An error occurred: {e}")

Discussion