LEGOモザイク変換でRGB最近傍が肌色を壊す理由
目次
写真をLEGOモザイクに変換するとき、RGBで一番近い色を選ぶだけだと肌色が緑に寄る。
髪は暗い塊になり、毛並みや輪郭も潰れる。
BMBrickのHow I Built a Perceptual Color Quantization Engine for LEGO Mosaicsは、この失敗をOKLab、素材差の重み、局所的なノイズ除去で直したという短い実装メモだった。
読むだけだとピンと来なかったので、Node + sharpで45色のLEGO風パレットを組み、白背景のメイドかなちゃん画像と渋谷スクランブル背景のセーラーかなちゃん画像を、それぞれ48x48にして4方式の出力を並べた。
複雑な背景のほうではRGB最近傍が2304ピクセル中368ピクセル(約16%)にメタリック銀・グリッター・透明パーツを混ぜてきて、白セーラー服にラメと銀粉を振りかけたような結果になる。
OKLab + 素材ペナルティに変えるだけで非solidパーツは0になり、人間が見て「ふつうのLEGOで組んだ写真」に見える。
この話はLEGO専用ツールの小ネタに見えるが、実際には固定パレット変換の落とし穴そのものだ。
ドット絵変換でも同じ罠を何度も踏んでいる。
JS減色だけで写真をドット絵化した Qwen Image Editで写真をドット絵に変換できるか試した では、Median Cutとnearest-neighbor縮小だけでは肌が浮いてしまい、結局Illustrious i2i + pixel-art-xl LoRAに切り替えた。
別ルートを試した Z-Image i2iでドット絵変換できるか試した でも、見た目を決めるのはLoRA選定よりもパレットと縮小の組み合わせのほうだった。
LEGOモザイクはこの系列と違って、AIに「いい感じに描いて」と頼める出力ではない。
物理的に存在する公式パーツへ押し込む必要があるので、距離関数と後処理の設計から逃げられない。
RGB距離は人間の見た目とずれる
RGBのユークリッド距離は計算しやすい。
あるピクセル (r, g, b) と候補色 (R, G, B) の差を、だいたい次のように測る。
const dist =
(r - R) ** 2 +
(g - G) ** 2 +
(b - B) ** 2;
この距離は、ディスプレイ上の数値差としては自然だ。
しかし、人間の目が感じる「近い色」とは一致しない。
同じ数値差でも、暗部・明部・彩度の違いで目立ち方が変わる。
肌のように少しの色相ズレが気持ち悪く見える領域では、このズレがかなり目立つ。
BMBrickはここをOKLabに変えている。
OKLabは2020年にBjoern Ottossonが提案した知覚均等に近い色空間で、数値上の距離が見た目の差に近くなるよう設計されている。
昔からあるCIELABと同じく「人間が見て近いか」を距離計算に持ち込むための道具だが、実装しやすく、CSS Color 4の oklab() / oklch() としても広がっている。
「知覚均等」という言葉が抽象的に聞こえるなら、以前書いた 可視スペクトルを正確にレンダリングする話 のAbney効果やCIE 2012色等関数のあたりが背景になる。
人間の網膜と脳は、物理的な波長やRGB成分の差をそのまま色差として知覚しない。
青〜紫では色相のわずかなズレに敏感で、暗部では明度差より色相差のほうが目立つ。
OKLabはこの不均一を距離計算上で吸収するための補正であり、肌のように敏感な領域でRGB距離が破綻する理由もここにある。
固定パレット変換では、ここが最初の分岐になる。
RGBで近い色を選ぶと、数学的には正しいのに見た目が変な結果になる。
OKLabやLabで近い色を選ぶと、少なくとも「なぜその色を選んだのか」が人間の見え方に寄る。
LEGOは色だけでなく素材も候補になる
原典で面白かったのは、OKLabに変えただけで終わっていないところだ。
LEGOの赤は単なる赤ではない。
マットな赤、透明な赤、メタリック、グリッターのように、素材で反射の仕方が変わる。
コード例では、OKLab距離に素材のペナルティを足していた。
function colorDistance(pixel, brick) {
const labDist = oklabDistance(pixel, brick.color);
const materialPenalty = pixel.material !== brick.material ? 0.15 : 0;
return labDist + materialPenalty;
}
この 0.15 は魔法の正解値ではなく、実物の見え方を距離関数に混ぜるための重みだ。
写真上では近い赤でも、透明パーツを顔の影に使うと質感が浮く。
RGBやOKLabの距離だけでは、この「素材が違うせいで変に見える」を扱えない。
これはLEGO以外にもそのまま当てはまる。
刺繍糸、ビーズ、印刷インク、電子ペーパー、限られたCSSカラートークンなど、最終出力の素材や表示環境が固定されている場合、色差だけで候補を選ぶと外す。
固定パレット変換は、パレットのRGB一覧を持てば終わりではなく、各色の使いやすさや質感差も含めた制約問題になる。
Median Cutはパレットを作るが、候補の正しさまでは見ない
色の量子化アルゴリズムには2つの役割が混ざっている。
ひとつはパレット生成(どのN色を採用するか)、もうひとつは割り当て(各ピクセルをどの色に置き換えるか)だ。
Median Cut、K-means、Octree、NeuQuantのような有名どころは、主に前者を扱う。
このブログの ドット絵変換ツール は、画像からMedian Cutでパレットを作り、各ピクセルを一番近いパレット色に置き換える。
この方式は、自分で使う簡単なドット絵化には十分速い。
ブラウザのCanvasだけで動き、ファイルをサーバーに送らずに済む。
ただし、Median Cutがやるのは「元画像から代表色を作る」ことだ。
LEGOモザイクのように、使える色が既に決まっているケースでは話が少し違う。
入力画像から理想の代表色を作るのではなく、公式パーツとして存在する色へ押し込む必要がある。
このとき効くのは前者のパレット生成ではなく、後者の割り当て、つまり距離関数の良し悪しのほうだ。
graph TD
A[入力写真] --> B[縮小]
B --> C[各ピクセルの色]
C --> D[固定LEGOパレット]
D --> E[知覚距離と素材重みで候補選択]
E --> F[局所ノイズを抑える]
F --> G[組めるモザイク]
自前のドット絵変換なら、パレットそのものを入力画像に合わせて作れる。
LEGOや印刷では、出力側のパレットが先にある。
この違いが大きい。
48x48のかなちゃんで4方式を並べた
実物を見ないと話が抽象的すぎるので、Node + sharpで45色のLEGO風パレットを組み、入力画像2枚を48x48に縮小して4方式の出力を作った。
1枚は白背景にメイド服のかなちゃん(背景73%が白で塗り分けがほぼ要らない単純な絵)、もう1枚は渋谷スクランブル風の人混み・看板・空・建物の中にセーラー服のかなちゃんが立つ複雑な絵。
同じ距離関数のクセが、画面の中の候補色の散らばり方でどう増幅されるかを見たい。
パレットは公式LEGOの実在色を参考にしたsolid 37色に、距離関数の比較が見えるようTrans 5色、Metallic 2色、Glitter 1色を足してある。
入力素材は写真なのですべて solid 扱い、Trans/Metallic/Glitterに対する素材ペナルティは原典どおり 0.15 にした。
OKLab変換は linear sRGB → LMS → 立方根 → 行列 の素朴な実装で、毎ピクセル変換するのではなくパレット側を先に変換してキャッシュする。
function linearToOklab(r, g, b) {
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s);
return {
L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
};
}
despeckleは、注目ピクセルの4近傍を見て3つ以上が同じ色なら多数派へ置き換える、というだけの簡易版にしてある。
8近傍にしたり閾値を5にしたりするバージョンもあり得るが、まずは効果が出るかだけ知りたい。
単純な背景: 白バックのメイドかなちゃん

これを48x48にしてから、4方式で塗り分ける。




| 方式 | Solid | Trans | Metallic | Glitter | 肌系上位色 |
|---|---|---|---|---|---|
| RGB最近傍 | 2097 | 0 | 52 | 155 | Medium Nougat 96, Nougat 49, Bright Pink 82 |
| OKLab最近傍 | 2125 | 0 | 49 | 130 | Medium Nougat 113, Light Nougat 73, Nougat 43 |
| OKLab + material | 2304 | 0 | 0 | 0 | Medium Nougat 113, Light Nougat 105, Nougat 44 |
| OKLab + material + despeckle | 2304 | 0 | 0 | 0 | Medium Nougat 125, Light Nougat 100, Nougat 41 |
このケースでは白背景が48x48中1686〜1695ピクセル(約73%)と画面の大半を占める。
非solidパーツが混入するのは合計207→179ピクセル(約9%→約8%)で、despeckleで変わるのも44ピクセル(約1.9%)にとどまる。
それでも顔に注目するとOKLabに切り替えた瞬間にLight Nougatが73→105ピクセルへ増えていて、肌の明るさが期待に寄る方向に動いている。
複雑な背景: 渋谷スクランブル風セーラーかなちゃん
候補色の散らばりが大きいほど距離関数の癖がはっきり出るので、人混み・看板・空・建物・路面のグラデーションを背景にしたケースで同じ実験を回す。





| 方式 | Solid | Trans | Metallic | Glitter | 上位色 |
|---|---|---|---|---|---|
| RGB最近傍 | 1936 | 51 | 97 | 220 | Dark Bluish Gray 254, Medium Nougat 244, Glitter 220 |
| OKLab最近傍 | 1979 | 62 | 106 | 157 | Medium Nougat 279, Dark Bluish Gray 214, Glitter 157 |
| OKLab + material | 2304 | 0 | 0 | 0 | Medium Nougat 279, Light Bluish Gray 221, Dark Bluish Gray 214 |
| OKLab + material + despeckle | 2304 | 0 | 0 | 0 | Medium Nougat 288, Light Bluish Gray 232, Dark Bluish Gray 228 |
非solidパーツの混入はRGBで368ピクセル(約16%)まで膨らみ、白セーラー服の上で「桜吹雪と銀紙」状のノイズになる。
OKLabに切り替えると325ピクセルまで減るが、それでも14%。
素材ペナルティを足した瞬間にこれが0になり、despeckleで139ピクセル(約6%)が多数派へ吸われる。
2ケースを並べてわかったこと
候補色の散らばりは距離関数の癖を増幅する。
単純なケースでも非solidは9%混じるが、複雑なケースでは16%まで膨らみ、despeckleの効果も1.9%→6%へ広がる。
逆に言えば、単純な絵で動いた距離関数が、絵が複雑になった途端に破綻するということでもある。
OKLabは知覚距離としては正しい方向に動いている。
肌の主役色がMedium NougatからLight Nougatへ寄り、ピンクの斑点が減り、空のグラデーションが見える。
ただし非solid混入を完全には消せず、複雑なケースでは10%以上残る。
素材ペナルティ 0.15 は知覚距離の上に「実物の制約」を被せるための重みで、写真→solidパーツに絞り込む役を担う。
ペナルティを 0.01 まで下げたらラメは戻ってくるはずで、これは閾値ではなく重みだ。
despeckleは絵の複雑度に応じて効く範囲が変わる。
単純な絵ではほとんど暇だが、複雑な絵では空や髪の中の孤立ピクセルを百個単位で均す。
1ピクセル単位では正しいが「組んだら浮く」点を、安いコストで落ち着かせる処理になる。
さらに突っ込んで遊んでみる
ここまではBMBrickの原典をなぞる方向だった。
せっかくスクリプトを組んだので、原典が触れていない方向にも振ってみる。
ペナルティ係数 0.15 は7倍盛り
BMBrickのコード例では materialPenalty = 0.15 を使っていた。
記事の中ではこの数字の根拠まで踏み込んでいないので、複雑な背景の入力で 0.00 から 0.50 まで動かしてみた。
| penalty | Solid | Trans | Metallic | Glitter | 非solid合計 |
|---|---|---|---|---|---|
| 0.00 | 1979 | 62 | 106 | 157 | 325 |
| 0.02 | 2304 | 0 | 0 | 0 | 0 |
| 0.05 | 2304 | 0 | 0 | 0 | 0 |
| 0.15 | 2304 | 0 | 0 | 0 | 0 |
| 0.50 | 2304 | 0 | 0 | 0 | 0 |
0.00 は単に「OKLab最近傍」と同じ(前のセクションの結果と一致)。
0.02 の時点で非solidパーツの混入が完全に0になる。
つまり原典の 0.15 は、消すために必要な値の7倍以上だ。

写真→solid限定パーツの組み合わせなら、これは「強めに振っておけば安全」という性質の重みであって、繊細にチューニングする必要はない。
逆に、本当にラメパーツを使いたい絵(夜空・水面・宝石)では 0.15 のままだと永遠に出番が来ないので、もっと小さい値か、領域単位で切り替える必要がある。
45色仕込んで実際に使われたのは何色か
OKLab + 素材ペナルティ 0.15 で塗ったときに、パレットの45色のうち実際にどの色が使われたかを集計した。
| 入力 | 使用色数 / 45 | 出番ゼロ |
|---|---|---|
| 単純(メイドかなちゃん) | 16色 | 29色 |
| 複雑(渋谷かなちゃん) | 28色 | 17色 |
両方のケースで一度も呼ばれなかった色は、当然ながらTrans 5色・Metallic 2色・Glitter 1色の合計8色(素材ペナルティで弾かれる)。
それ以外にもCoral、Yellow、Orange、Green、Bright Green、Lime、Olive Green、Dark Purple、Lavenderあたりが両方とも未使用で、純度の高い鮮やかな色は写真ベースのモザイクではほぼ出番がない。
逆に複雑なケースで上位に来たのはMedium Nougat 279(肌+髪)、Light Bluish Gray 221(看板・路面)、Dark Bluish Gray 214(影・スカート)、Medium Brown 203(髪の影)、White 201(セーラー服)、Sand Blue 197(空・遠景)あたりで、写真の中の中間調を担う淡い色が画面の大半を占める。
「LEGOで写真をモザイクにしたい」という目的に対して、純色の派手なパーツはむしろ買わなくていい。
中間グレー・ベージュ・スカイブルー・肌色系を厚めに揃えるほうが効くわけで、これは実際にBrickLinkで部品を集めるときの戦略にもなる。
肌色パーツが店に並んでなかったら肌は何色になるか
Light Nougat / Medium Nougat / Nougat の3色をパレットから抜いて、複雑なケースを同じ条件で塗り直した。

肌の主役だった Medium Nougat 279, Light Nougat 186, Nougat 85 が消えると、その分が Light Bluish Gray 279, White 269, Tan 233, Dark Tan 192, Medium Brown 204 に流れる。
要するに肌色エリアが灰色と薄茶のまだら模様に置き換わり、顔として読めなくなる。
LEGOでアニメ調のキャラクターを作るとき、肌色(Nougat系3色)が手元にあるかどうかで読みやすさが完全に変わるという話で、ここを後から買い足すと一気に印象が直る。
スターター10色だけで組んだらどうなるか
「公式のスターターBOXに入ってる色だけで組みたい」みたいな縛りを想定して、White / Black / Red / Blue / Yellow / Green / Light Bluish Gray / Dark Bluish Gray / Reddish Brown / Orange の10色だけのパレットで複雑ケースを変換した。

色の内訳: Light Bluish Gray 831, Dark Bluish Gray 695, White 477, Reddish Brown 108, Black 93, Yellow 42, Red 35, Orange 19, Blue 4。
Greenは10色の中で唯一一度も呼ばれなかった。
画面の66%(1526/2304ピクセル)がグレー2色で埋まり、肌は完全に消滅、髪はReddish Brownのベタ塗りになって、赤いスカーフだけがRedの塊として鮮やかに生き残る。
LEGOで何でも作れるわけじゃないというのが視覚的にわかる結果で、最低限「肌色3色+ベージュ系2色+茶系3色」あたりが揃っていないと人物モザイクは苦しい。
何ピクセルまで縮めると元絵が消えるか
ここまで48x48でやってきたが、解像度を落とすとどこから人物として読めなくなるかも気になる。
複雑ケースを48 / 24 / 16 / 12 / 8 で同じパイプライン(OKLab + 素材ペナルティ + despeckle)にかけた。




使用色数も解像度に応じて頭打ちになる:
| サイズ | ピクセル数 | 使用色数 |
|---|---|---|
| 48x48 | 2304 | 28 |
| 32x32 | 1024 | 26 |
| 24x24 | 576 | 22 |
| 16x16 | 256 | 19 |
| 12x12 | 144 | 15 |
| 8x8 | 64 | 14 |
16x16の時点でまだ19色も使っていて、ピクセル数256のうち16〜52ピクセルしか割り当たらない色が量産される。
「8x8で人物モザイクを作る」みたいな超低解像度では、距離関数より先にパレットを絞ったほうが見栄えが良くなるはずで、これも別の最適化軸になる。
体感の境界線を素直に書くと、こんな感じになる:
- 48x48: 顔の輪郭・髪の流れ・スカーフの位置までわかる
- 32x32: 顔の細部は怪しいが、誰の絵か知ってれば普通に読める
- 24x24: 遠くから薄目で見れば「あ、ヒトだ」とわかる、くらい
- 16x16: 元絵を知らずに見ても人だとは気付かない、言われれば「そう見えるかも」のレベル
- 12x12 以下: 上に空、中央に肌色、下に赤い点、というブロック配置の話
ただしこの判定は元絵を知ってる状態でやっているので、初見の他人が同じ境界を引くわけじゃない。
「これがかなちゃんだ」と知ってる脳は、足りない情報を勝手に補ってしまうため、上の体感はおそらく1〜2段甘い側に振れている。
LEGOで実際に組むときの目安としては、誰が見ても人だとわかるレベルが欲しければ 32x32 以上、表情まで残したいなら 48x48 以上が必要、というのが今回の手元での結論になる。
点で正しくても面ではうるさい
BMBrickは色選択の後に、4x4ブロック単位のアンカーと、孤立ピクセルを置き換えるdespeckle処理を入れている。
単一ピクセルだけを見ると正しい色でも、周囲から浮いた1ドットはモザイク全体ではノイズになる。
これはドット絵でも同じだ。
1ピクセル単位で正確に近い色を選ぶほど、写真由来の細かいザラつきが残る。
人間が見たいのは元写真の全ピクセルの忠実な再現ではなく、顔、髪、目、輪郭が読めることだ。
LEGOモザイクならなおさらで、1x1パーツを1個置くたびに物理的なコストが発生する。
この手の変換では、忠実度と可読性がぶつかる。
エッジは残したいが、ランダムな斑点は消したい。
肌のグラデーションは滑らかに見せたいが、目や口の境界は潰したくない。
BMBrickの「block anchor + despeckle」は、その妥協をかなり素直な後処理として入れている。
ブラウザ実装で見る場所
原典の末尾には、vanilla JS、Canvas API、Web Workersで作ったとある。
この構成なら、写真をブラウザ内で読み込み、Canvasで縮小し、ピクセル配列を走査して、候補色へ置換できる。
重いのは全ピクセルに対する全候補色の距離計算なので、48x48や64x64のモザイクなら現実的だ。
たとえば64x64なら4096ピクセル。
候補色が100色あっても、距離計算は約40万回で済む。
Web Workerに逃がすほどではない場面も多いが、プレビューをスライダーでリアルタイム更新するならメインスレッドから切り離したくなる。
実装で気にするところは、アルゴリズム名よりもデータの置き場所だ。
毎回RGBからOKLabへ変換すると無駄が出るので、固定パレット側のOKLab値は先に計算して持っておく。
素材ペナルティや在庫の有無も、候補色のメタデータとして一緒に持つ。
入力ピクセル側だけを変換し、候補配列をなめる形にすれば単純に保てる。
SVGで幼児向けウェブ塗り絵を作った話では、ピクセル単位の領域判定を避けるためにSVGのパスをそのまま塗り領域にした。
今回のLEGOモザイクは逆で、ピクセル単位の処理を避けられない。
だからこそ、距離関数と後処理の設計が見た目に直結する。
AIで補完する前に距離関数を見る
写真を小さな絵に変換するとき、最近はすぐAIでスタイル変換したくなる。
実際、ドット絵変換の記事では、最終的にIllustrious i2i + pixel-art-xl LoRAのほうがJS減色より見た目はよかった。
ただ、LEGOモザイクのように「実物の部品で組めること」が条件になると、生成AIだけでは最後の制約を満たせず、知覚距離・素材重み・局所ノイズ除去という地味な処理に戻ってくる。