- 最終目標はanimationをかけること
- live2D的な機能を最終的に求める
assets/SD.png の SD キャラを、写真を一切使わず JavaScript だけで描画している。
shapes.js は座標と色だけのデータで、実行時に画像は一切読み込まない。
tools/analyze.py 画像を解析して shapes.js を生成する (開発時のみ実行)
tools/strokes.py 線画を中心線 + 線幅のストロークに変換する
shapes.js 自動生成。パスの d 属性・色・グラデーション・部位ラベル
main.js shapes.js から SVG を組み立てて描画する
index.html 表示用
現在 1130 パーツ (面 330 + 線 800)。各パーツには data-label
(hair / skin / iris / eyeLine / hoodie / legs / shoes / earInner) が付いていて、
部位単位でアニメーションさせるための下地になっている。
python3 -m http.server 8000python3 tools/analyze.pyassets/SD.png を読んで shapes.js を上書きする。1 分弱かかる。
- 背景除去 — 画像の外周から白のまま繋がっている領域だけを背景とみなす。 単純な白判定だと髪やパーカーの白まで消える。
- 減色 — メディアンで階調をならし、CIE Lab 空間で k-means。色度 a*b* を 強調してあるのは、面積の小さい瞳の紫が髪の薄紫クラスタに吸収されるのを防ぐため。
- パーツ分割 — 色クラスタを連結成分に分け、
面積 x 重要度で採否を決める。 重要度は彩度と暗さから出す。まつ毛のように小さくても効くものを残すための重み。 - 輪郭 — marching squares → 閉曲線 RDP → Catmull-Rom → 3 次ベジェ。
- 完全分割 — 採用しなかった画素を同色クラスタの最近傍パーツに吸収させる。 前景に隙間が残らないので、パーツの継ぎ目から下地が透けない。
- グラデーション — パーツごとに色を位置の線形関数として最小二乗で近似し、
linearGradientにする。単色で塗ると階調が段々になる。 - 残差 ROI — ここまでの結果を塗り直した画像と原画を Lab で比べ、誤差の 大きい領域だけを細かい設定で再解析する。顔や口の位置を決め打ちしなくても、 どこが再現できていないかを画像自身が教えてくれる。
- 線画 — 残差を見ると、面はほぼ再現できていて残りはほとんど線画だった。 原画は「線画 + 塗り」なので、black-hat で細い線だけを取り出して最前面へ重ねる。 OKOJO ロゴや髪の流れが戻るのはこの段階。
線を塗りつぶしの面として描くと、細いマスクの輪郭が往復して棘だらけになる。 それを平滑化で消そうとすると、細い線ごと消える — 「棘を消す」と「細い線を残す」 が同じつまみになってしまう。実際、マスクのぼかしを sigma 1.1 まで上げた時点で 顎の輪郭線と OKOJO ロゴの「J」が消えた。
tools/strokes.py は線を中心線と線幅に分解する。
- Zhang-Suen 細線化で 1px の中心線にする
- 端点から辿ってポリラインにする。分岐点では進行方向に最も近い枝へ進む —
機械的に切ると、髪のように何度も交差する絵では 1 本の流れが断片に割れる。
直進する枝が無ければそこで切る (無理に曲がると別の線に飛び移る)。
また分岐点は
visitedで占有しない。占有すると先に通った線が交差点を取り、 他の線がそこで途切れる。 - 線幅は距離変換の値の 2 倍 (中心線上の値 = 中心から縁までの距離)
こうすると平滑化の強さと線の細さが独立するので、棘だけを消せる。 変形させたときも線幅が追従するため、部位ごとのアニメーションにも向く。
main.js が同じラベルのパーツを <g> にまとめ直し、グループごと transform する。
現在動いているのは目・口・アホ毛。
- まばたき — 2〜6 秒のランダム間隔。22% の確率で二度続けて瞬きする。 等間隔だと機械的に見える。閉じるより開くほうを緩やかにしている
- 口 — 呼吸に合わせて縦に開閉。原画の口は 5px しかないので動きは控えめ
- アホ毛 — 周期の違う 2 つの正弦波を重ねて、根元を軸に回す
色から推測した guess_label は面の分類には足りるが、部位としては当てにならない
(白目が "hair" に分類されるなど)。動かす部位は瞳の位置を基準に取り直していて、
その閾値は画面上でパーツに色を塗り、目視で確かめて決めた。
目のグループには一つ仕掛けがある。まつ毛と瞳は暗いので色で拾えるが、白目は肌より
明るいので拾えない。置き去りにすると、目を閉じたときにそこだけ白い穴が残る。
そこで main.js 側で、まつ毛と瞳が作る外形 (getBBox) の内側にあるパーツを、
明るさに関係なく取り込んでいる。頬はこの外形の下にあるので巻き込まれない。
取り込むと重なり順が崩れるので、解析時の depth で並べ直している。
髪は 684 パーツあるが、「髪」と「髪でない部分」の切り分けはできていない
(guess_label が色ベースなので、白目や前髪の一部が混ざる)。髪を揺らすには
ここを目視で分け直す必要がある。
analyze.py は各段階で平均 Lab 距離を出すが、これは面の再現度の指標。
線画を重ねると数値はむしろ悪化する — 線は面積が小さく、1px ずれるだけで
両側に誤差が出るため。見た目の良し悪しはこの数値では決まらない。