なぜ「Pixel Snapper」は魔法のようにドット絵を復元できるのか? — 平凡なスクリプトとの決定的違い
この記事は、Gemini3-previewで書きました。元の平凡なコードは私の自作(MIT)
Gemini3に書かせたpython版も、MITライセンスです。

素晴らしい、Sprite Fusion 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クラスタリング)を使います。
- パレット分析: まず画像全体を見て、「この絵は、実はこの16色だけで構成されているはずだ」と分析します。
- ノイズ除去: どんなに微妙なグラデーションや汚れがあっても、その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