AI Dev Lab
DotCraft

DotCraft ができるまで — 写真をドット絵に変える減色とディザ

写真をドット絵にするツールの開発ログ。 ボックス平均での縮小、 メディアンカットによる減色、 Bayer と Floyd-Steinberg のディザを組み合わせ、 レトロゲーム機の配色に寄せるまでを記録しています。

DotCraft は、写真をドット絵(ピクセルアート)に変換するブラウザツールです。縮小・減色・ディザリングの 3 段で、ゲームボーイやファミコンのような配色に寄せます。処理はすべて端末の中で完結します。

#🎯 縮小するだけではドット絵にならない

最初にやってみたのは「画像を小さくして、そのまま拡大する」でした。結果はただのぼやけた画像です。

理由は 2 つありました。

  1. 縮小時の補間で色が混ざり、中間色が大量に生まれる
  2. 拡大時の補間でドットの角が丸まる

ドット絵は「少ない色数」と「はっきりした四角い画素」でできています。つまり必要なのは、縮小・減色補間なしの拡大の 3 つでした。

#📉 縮小はボックス平均で

縮小はブラウザ標準の描画に任せず、自前でボックス平均を取っています。出力の 1 マスに対応する元画像の矩形をまるごと平均する方式です。

標準の縮小はブラウザによってアルゴリズムが違い、結果が環境ごとにぶれます。自分で平均を取れば、どこで見ても同じマス目になります。

#🎨 メディアンカットで代表色を選ぶ

減色はメディアンカットを使いました。k-means より速く、色の偏った画像でも破綻しにくい手法です。

考え方はこうです。

  1. 全画素を 1 つの箱に入れる
  2. いちばん幅の広いチャンネル(R/G/B のどれか)を探す
  3. その軸で中央値を境に箱を 2 つに割る
  4. 目標の色数になるまで 2 と 3 を繰り返す
ts
function channelRange(box: Box): { ch: number; range: number } {
  // R / G / B それぞれの min と max を取り、
  // いちばんレンジの広いチャンネルを返す
}

**「散らばりの大きい方向から割る」**というのが肝です。ランダムに割ると、ほとんど使われていない色に代表色を 1 つ消費してしまいます。幅で選べば、実際に使われている色域に代表色が配られます。

k-means と違って反復も乱数もないので、同じ画像からは必ず同じパレットが出ます。ドット絵は「作り直したら色が変わった」が困る用途なので、決定的であることを優先しました。

#🌗 ディザリング ── 少ない色で階調を作る

色数を 4 色や 16 色まで落とすと、グラデーションが縞になります(バンディング)。これを散らすのがディザリングです。2 種類入れました。

Bayer(順序ディザ) は、あらかじめ決まった閾値の行列を画面に敷き詰めて、そのマスの閾値と比べて色を決めます。規則的な網目が出るのが特徴で、レトロゲームの見た目に近くなります。

Floyd-Steinberg(誤差拡散) は、丸めたときに出た誤差を隣の画素に配ります。

ts
function diffuse(rgb, gw, gh, x, y, er, eg, eb, f) {
  const gi = (y * gw + x) * 3
  rgb[gi] += er * f
  rgb[gi + 1] += eg * f
  rgb[gi + 2] += eb * f
}

右に 7/16、左下に 3/16、下に 5/16、右下に 1/16 という配分で誤差を送ります。捨てるはずの端数を隣に押し付けるだけの発想ですが、これで少ない色数でも滑らかな階調に見えます。

どちらが良いかは用途次第で、規則的な模様が欲しければ Bayer、自然な階調なら Floyd-Steinberg です。両方選べるようにしました。

#🕹️ レトロ機のパレット

ゲームボーイ(緑 4 階調)、ファミコン、PICO-8 などのパレットを用意しました。これらは実機の仕様として色が決まっているので、そこへ最近傍で写せば当時の見た目になります。

ts
function nearest(palette: RGB[], r: number, g: number, b: number): RGB

自由に減色するモードと、固定パレットに寄せるモードの両方を持たせています。前者は写真の色を活かし、後者は「その機種の絵」になります。

#🔍 拡大は補間なしで

最後の拡大はニアレストネイバー(最近傍)です。Canvas なら次の一行で切り替わります。

ts
ctx.imageSmoothingEnabled = false

これを忘れると、せっかく作ったドットが拡大時にぼやけて全部台無しになります。ドット絵ツールで一番効く一行がこれでした。

#😓 苦労したところ

明るさとコントラストの前処理が要った。 元の写真をそのまま減色すると、暗い写真は暗いまま潰れます。減色の前に明るさとコントラストを調整する工程を入れて、階調が残っている状態にしてから色を削る順序にしました。

「何色になったか」を見せる。 減色は結果を見ても何色使われているか分かりません。変換後にユニーク色数を数えて表示するようにしました。

ts
export function countUniqueColors(result: PixelResult): number

数字が出るだけで、パレットのつまみを触る意味が分かるようになります。

グリッドサイズと色数のどちらを触るべきか迷わせない。 つまみが多いと止まります。プリセット(ゲームボーイ / ファミコン / PICO-8 など)を先に選ばせて、細かい調整は下に畳む構成にしました。

#🔭 今後の拡張

  • アニメーション GIF のフレームごと変換
  • パレットの手動編集(色を差し替える)
  • スプライトシート書き出し
  • 輪郭を強調してから減色するモード(キャラクター向け)

#💡 このサービスから言えること

「小さくする」と「ドット絵にする」は別物でした。 縮小・減色・補間なし拡大の 3 つが揃って初めてドット絵になります。どれか 1 つ欠けると、ぼやけた画像か、縞の出た画像にしかなりません。

そして減色では、決定的であることを性能より優先しました。メディアンカットは k-means より少しだけ粗いですが、同じ入力から必ず同じ結果が出ます。作り直しが前提の道具では、そちらのほうが価値があります。

[ ./next_action ]

読んだら、 DotCraft を実際に動かす。

この開発ログは DotCraft をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →