技術約9分で読めます

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を口開きに回した。

編集した絵は、指示していない目の描き方まで少し変わっていた。
そこで編集結果をそのまま使わないで、目の範囲と口の範囲だけを土台に重ね、髪・体・輪郭は土台のピクセルのまま全フレームで同じにした。

差分の顔アップ5枚。左から微笑・半目・閉じ目・口半開き・口開き

目の差分を貼る範囲の調整

最初は目の範囲の下端が頬にかかっていて、半目の差分(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目の範囲が画面の横幅いっぱい。これで決めた

切り抜きの比較4枚。左上が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回。

怒りのまばたき比較3枚。左が怒りの土台、中が微笑の閉じ目を流用して眉が消えたもの、右が怒りの閉じ目を作り直したもの

目と口の範囲の境目で出た色の差

表情ごとに組み上げた9枚を、実機と同じ土台とパーツに分けて貼り直すと、最初は合成済みのフレームと最大82階調違った。
差の出ている場所を調べると、目の差分の下端と、口の差分を貼るときのぼかしが、上から155px目の境目を少しはみ出していた。

合成するときに、目は境目より上、口は境目より下だけを差分から使うようにすると、差は表情ごとに最大2〜6階調まで下がった。

できた表情

実機と同じ320x240の切り抜きで、表情ごとに目と口の差分を揃えた。上から喜・怒・哀・楽、左から通常・半目・閉じ目・口半開き・口開きの順に並べている。

喜怒哀楽の4表情×5状態の一覧。上から喜・怒・哀・楽、左から開いた目、半目、閉じ目、口半開き、口開き

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

喜の目ぱちと口パク 怒の目ぱちと口パク 哀の目ぱちと口パク 楽の目ぱちと口パク

頭のタッチで表情を切り替えた

実機では、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

画面タップでの口パクもこの版に残している。
下の動画は、頭を叩いて微笑から楽まで一周させたもので、画面には触っていないので口は閉じたまま目ぱちだけしている。