StackChanの顔をかなちゃんの絵にして喜怒哀楽を付けた
目次

2026-09-25 追記: 頭のタッチと組み合わせて音声チャットに統合し、誤検出対策や口パクを入れた → StackChan Bodyを音声チャットにつないだら、触っていないのに録音していた
前に口パクの記事でCoreS3に付けた顔は、320x240の画面の中央に128x128のドット絵を置いて、口を3段階で切り替えるものだった。
CoreS3を、首のサーボと頭のタッチパッドが付いたM5Stack公式のStackChan Bodyに載せたので、顔もかなちゃんの絵にして、画面いっぱいに出してみた。
検証環境
| 項目 | 内容 |
|---|---|
| 本体 | M5Stack CoreS3(StackChan Bodyに装着) |
| 土台の絵 | ローカルのComfyUI(M1 Max 64GB)、WAI-Anima+かなちゃんLoRA+Turbo LoRA |
| 差分の生成 | ModelScope API-InferenceのQwen-Image 2.1(編集) |
| 合成・縮小 | Python(Pillow、NumPy) |
| 書き込み | Arduino CLI(ボード指定は esp32:esp32:m5stack_cores3) |
| ライブラリ | M5Unified(M5Stack公式の画面・タッチ等のライブラリ)、StackChan-BSP(StackChan Bodyのサーボ・タッチ用) |
土台の絵
白黒アイコンをAnimaの絵柄で描き直した記事と同じモデルとLoRAで、デフォルメした上半身を正面向き・832x1024で出力した。
プロンプトでデフォルメの強さを決める chibi の重みは、1.0だとちびキャラになりすぎたので、0.8、0.7、0.6と下げて比べ、0.7・シード(乱数の初期値)424247の真顔に決めた。
この真顔をQwen-Image 2.1で微笑に編集し、口の範囲だけを真顔に貼った。
以降の差分は、すべてこの微笑の絵を土台にしている。

目と口の差分をQwen-Image 2.1で作る
微笑の土台から、差分ごとに801〜803の3つのシードで編集して出した。
指示文には変える部分だけを書き、それ以外はそのままにするよう書いている。
| 差分 | 指示の中身 | 使ったシード |
|---|---|---|
| 半目 | まばたき途中でまぶたが半分閉じる | 801 |
| 閉じ目 | まばたきで完全に閉じる | 803 |
| 口半開き | 何か言っている途中の小さく開いた口 | 801 |
| 口開き | 話しているときの大きく開いた口 | 使わず(口半開きの803) |
半目の802と803は、片目だけ閉じたウインクになっていた。
口開きは3枚とも開きすぎたので、口半開きのうち大きめに開いた803を口開きに回した。
編集した絵は、指示していない目の描き方まで少し変わっていた。
そこで編集結果をそのまま使わないで、目の範囲と口の範囲だけを土台に重ね、髪・体・輪郭は土台のピクセルのまま全フレームで同じにした。

目の差分を貼る範囲の調整
最初は目の範囲の下端が頬にかかっていて、半目の差分(801)は頬が赤くなっていて、貼るとまばたきのたびに頬の色が変わった。
目の範囲を上げて頬を外すと、今度は閉じ目のまぶたの下に、土台の開いた目の下半分がうっすら透けて出た。
そこで目の範囲の下端の数十ピクセルだけは、まぶたの線や黒目のような暗い部分を差分の絵から使い、明るい肌は土台のままにした。
閉じ目の下に透けていた目も、頬の赤みも出なくなった。
APIへの送信が遅かった
ModelScopeのAPIには、参照画像を公開URLに上げず、Base64の文字列をURLの形式にしたデータURLで送れた。
ただ、最初は1枚あたり3〜5分かかった。
参照画像のPNGをBase64にすると約1MBあった。
転送時間だけ測ろうと認証なしでAPIに送ると13秒(約66KB/秒)で、2回目は6KB/秒まで下がって途中で止まった。
同じデータをGoogle宛てに送ると0.2秒(約4.8MB/秒)だったので、この環境ではModelScopeへの上りの転送が遅いようだった。
差分は目と口の範囲しか使わず、フレームはPNGの土台に貼って作っているので、参照画像はJPEG(品質95)にして約206KBまで減らした。
1枚30〜80秒で返ってくるようになった。
WebPのアニメでタイミングを確かめた
目ぱちは、開いた目を1.4秒見せてから、半目60ms、閉じ目90ms、半目60msで戻すようにした。

口は閉120ms、半120ms、開180ms、半120msを1音にして、その上にまばたきを重ねた。

顔を画面いっぱいに切り抜く
StackChan用に最初に用意した素材は、顔のまわりを正方形に切り抜いた240x240で、4:3の画面に置くと左右に40pxずつ白い余白ができた。
顔を画面いっぱいに出したいので、頭頂部や顎は切れてもいいことにした。
そこで832x1024の元画像から画面と同じ4:3で切り抜き、320x240に縮小し直した。
切り抜きの中心は目と口の範囲の真ん中に置いて、実機で確かめながら幅を狭めていった。
| 画像の位置 | 切り抜き | 結果 |
|---|---|---|
| 左上 | 240x240の素材をそのまま置く | 左右に白い余白(実機には出していない) |
| 右上 | 元画像で幅440px | 実機で確かめて、もう少し目と口を大きくすることにした |
| 左下 | 元画像で幅340px | 頭頂部と顎がほぼ切れた。もう少しだけ狭める |
| 右下 | 元画像で幅300px | 目の範囲が画面の横幅いっぱい。これで決めた |

幅を狭めた分だけ目と口の範囲も大きくなり、目のパーツは幅440pxの版の219x114pxから、幅300pxの版では320x155pxになった。
目と口をパーツに分ける
前回のドット絵と同じく、PNGをフラッシュ(プログラムを書き込む場所)に埋め込んでいる。
起動時にスプライト(画面の外で描いておく画像。M5Canvas)へ展開し、pushSprite() で画面に送っている。
前回の128x128は内部RAMに置けるサイズだったが、今回は320x240を1画素2バイト(16bit)にすると1枚150KBあるので、CoreS3に載っている8MBのPSRAM(外付けのRAM)に置いた。
目3種と口3種の組み合わせ9枚をそのままPNGでフラッシュに入れると、幅440pxの切り抜きでも約1.07MBになる。
9枚は目と口以外がすべて同じなので、容量を抑えるために土台1枚と、目のパーツ3枚・口のパーツ3枚に分けた。
起動時に土台を1回描いたあとは、目と口の長方形の範囲だけを上書きするようにした。
目の範囲と口の範囲は、境目で1行も重ならないように分けている。
最終的な幅300pxの版では、目の範囲が画面の上から0〜154px(横幅いっぱい)、口の範囲が上から155〜239px・左から75〜244pxになった。
パーツを組み直した画像と合成済みの9枚を比べると、幅440pxの版で最大21階調(256段階のうち)の差があった。
差が出ていたのは境目の行で、縮小したときのにじみと思われ、そのままにしている。
| 切り抜き | PNGの合計 | 書き込んだプログラム全体 |
|---|---|---|
| 幅440px、9枚そのまま | 1,068,909バイト | 書き込んでいない |
| 幅440px、土台+パーツ6枚 | 272,647バイト | 787,135バイト |
| 幅300px、土台+パーツ6枚 | 351,864バイト | 894,043バイト |
展開と上書きの時間は、最初に書き込んだ幅440pxの版で測った(幅300pxの版は、5表情の版で測った)。
| 項目 | 幅440pxの版の実測 |
|---|---|
| 起動時の展開 | 249ms |
| 展開後のPSRAMの空き | 約7.8MB |
| 目のパーツ(219x114px)の上書き | 最長約11ms |
実機で目ぱちさせた
目ぱちはずっと動かし、口パクは画面をタップすると始まり、もう一度タップすると止まるようにした。
下の動画は幅300pxの版で、画面や頭には触れずに目ぱちだけさせている。
喜怒哀楽を付けた
顔が微笑だけだったので、喜・怒・哀・楽の4つの表情も作った。
表情は眉から口まで顔全体が変わるので、微笑の土台から表情ごとにシードを3つずつ変えて編集で出し、顔全体の範囲を微笑の土台に貼ったものを、それぞれの表情の元にした。
| 表情 | 指示の中身 | 使ったシード |
|---|---|---|
| 喜 | 目を大きく開いてきらきらさせ、口を閉じて大きく笑う | 902 |
| 怒 | 眉を寄せて下げ、こちらをにらみ、口を閉じてむくれる | 902 |
| 哀 | 眉の内側を上げ、少し涙目で、口角を下げる | 902 |
| 楽 | 目を明るく開き、歯を見せてにっと笑う | 901 |
口の形は表情ごとに違うので、口の半開きと開きも表情ごとに3つずつ出した。
微笑のまばたきを怒りに使い回すと眉が消えた
閉じ目と半目は、最初は微笑のものを使い回した。
眉を除いた目の範囲だけを貼ってみたが、怒りの顔は眉が目のすぐ上まで下がっていて範囲が重なり、まばたきの間だけ怒りの眉が消えてしまった。
そこで閉じ目と半目も4つの表情ごとに編集で出し直し、指示には眉をそのまま残すよう書いた。
作り直した閉じ目では、まばたきの間も怒りの眉が消えなくなった。
APIを呼んだ回数は、表情の元12回、口24回、出し直した目24回の60回。

目と口の範囲の境目で出た色の差
表情ごとに組み上げた9枚を、実機と同じ土台とパーツに分けて貼り直すと、最初は合成済みのフレームと最大82階調違った。
差の出ている場所を調べると、目の差分の下端と、口の差分を貼るときのぼかしが、上から155px目の境目を少しはみ出していた。
合成するときに、目は境目より上、口は境目より下だけを差分から使うようにすると、差は表情ごとに最大2〜6階調まで下がった。
できた表情
実機と同じ320x240の切り抜きで、表情ごとに目と口の差分を揃えた。上から喜・怒・哀・楽、左から通常・半目・閉じ目・口半開き・口開きの順に並べている。

目ぱちと口パクを同時に動かしたアニメは、上から喜・怒・哀・楽。

頭のタッチで表情を切り替えた
実機では、StackChan Bodyの頭の上面にある静電容量式のタッチパッドを叩くたびに、微笑→喜→怒→哀→楽の順に表情が切り替わるようにした。
叩いたかどうかは、頭上タッチを試した記事と同じく、撫でる動きがなく、触れてから0.8秒以内に指が離れたかで判定している。
最初に書き込んだものは、起動直後に例外で止まって再起動を繰り返した。
シリアルモニタに出た例外の参照先アドレスが0x74で、ヌルポインタ(何も指していないポインタ)の近くを参照していた。
表情ごとのスプライトを配列で作ったとき、描画先の画面を設定していなかったため、pushSprite() に送り先の画面を指定するように直すと起動した。
| 切り抜き | PNGの合計 | 書き込んだプログラム全体 |
|---|---|---|
| 幅300px、5表情(土台+パーツ6枚ずつ) | 2,004,583バイト | 2,568,959バイト(このボード設定のアプリ領域3MBの81%) |
| 項目 | 5表情の版の実測 |
|---|---|
| 起動時に5表情を展開 | 2,076ms |
| 展開後のPSRAMの空き | 5,471KB |
| 表情の切り替え(土台と目・口を描き直す) | 約63ms |
| 目のパーツ(320x155px)の上書き | 最長約29ms |
画面タップでの口パクもこの版に残している。
下の動画は、頭を叩いて微笑から楽まで一周させたもので、画面には触っていないので口は閉じたまま目ぱちだけしている。