Tech11 min read

StackChan Face with Qwen-Image 2.1: 5 Emotions and Lip Sync on M5Stack CoreS3

IkesanContents

Update (2026-09-25): Integrated the face with head-touch voice chat, false-trigger filtering, and lip sync → StackChan Body Voice Chat on CoreS3: Head Tap, Ghost ASR Fix, Lip Sync

The face I previously added to the CoreS3 in the lip sync article placed a 128x128 pixel art sprite in the center of the 320x240 screen, switching the mouth across three stages.
Now that I have mounted the CoreS3 into a StackChan Body, which includes neck servos and a capacitive head touch pad, I tried replacing the face with illustration art of Kana-chan and filling the entire screen.

Test Environment

ItemDetails
HardwareM5Stack CoreS3 (mounted on StackChan Body)
Base IllustrationLocal ComfyUI (M1 Max 64GB), WAI-Anima + Kana-chan LoRA + Turbo LoRA
Diff GenerationModelScope API-Inference with Qwen-Image 2.1 (edit mode)
Compositing & ResizingPython (Pillow, NumPy)
FlashingArduino CLI (board identifier esp32:esp32:m5stack_cores3)
LibrariesM5Unified (official M5Stack display/touch library), StackChan-BSP (servo and touch support for StackChan Body)

Base Illustration

Using the same model and LoRA as in the article redrawing the monochrome icon in Anima style, I generated a stylized, chibi-style upper body facing forward at 832x1024.
In the prompt, the weight for chibi controlled how heavily deformed the character was. At 1.0 it looked too extreme, so I tested lower weights of 0.8, 0.7, and 0.6, settling on 0.7 with seed 424247 for a neutral expression.

I then edited this neutral face into a smile using Qwen-Image 2.1, pasting only the mouth region back onto the neutral face.
All subsequent facial differences are built on top of this smiling base image.

Base smiling illustration of Kana-chan. A stylized upper-body portrait facing forward

Generating Eye and Mouth Diffs with Qwen-Image 2.1

Starting from the smiling base image, I generated edits using three seeds (801 to 803) for each diff.
The prompt instructions specified only the parts to change, keeping everything else untouched.

DiffInstruction ContentSeed Used
Half-closed eyesEyelids half-closed midway through a blink801
Closed eyesCompletely closed eyes during a blink803
Half-open mouthSmall open mouth mid-speech801
Wide-open mouthWide open mouth while speakingNot used (used 803 from half-open)

For half-closed eyes, seeds 802 and 803 ended up winking with only one eye closed.
For the wide-open mouth, all three seeds produced mouths that opened too wide, so I repurposed seed 803 from the half-open set, which had opened somewhat wider.

The edited images also slightly altered eye details that were not part of the instructions.
Instead of using the full edited image directly, I only composited the eye and mouth bounding boxes onto the base, keeping the hair, body, and outline pixels identical across all frames.

Five facial diff closeups: smile, half-closed eyes, closed eyes, half-open mouth, and wide-open mouth

Adjusting the Eye Diff Bounding Box

Initially, the bottom edge of the eye bounding box reached down to the cheeks. In the half-closed eye diff (801), the cheeks had a blush, causing the cheek color to flicker with every blink when composited.
When I raised the bottom edge to exclude the cheeks, the original open eye from the base image faintly showed through beneath the closed eyelids.

To fix this, for the bottom few dozen pixels of the eye box, I took only darker pixels (eyelash lines and pupils) from the diff image, leaving bright skin pixels untouched from the base image.
This eliminated both the see-through eye beneath the closed lids and the cheek color shift.

API Upload Latency

With the ModelScope API, reference images can be sent as Base64 data URLs without hosting them at public URLs.
However, generation initially took 3 to 5 minutes per image.

The reference PNG converted to Base64 was about 1 MB.
When I tested the upload transfer time alone by posting to the API without auth headers, it took 13 seconds (about 66 KB/s), and a second run dropped to 6 KB/s before stalling.
Sending the same payload to a Google endpoint took only 0.2 seconds (about 4.8 MB/s), suggesting that upstream throughput to ModelScope was throttled from this environment.

Since the diffs only touch the eye and mouth bounding boxes and are composited onto the original PNG base, I converted the reference images to JPEG (quality 95), reducing the size to about 206 KB.
Response times improved to 30 to 80 seconds per image.

Timing Verification with WebP Animations

For blinking, I held the open eyes for 1.4 seconds, followed by half-closed for 60 ms, closed for 90 ms, and half-closed for 60 ms before returning to open.

Blinking animation

For speech, one mouth cycle comprised closed for 120 ms, half-open for 120 ms, wide-open for 180 ms, and half-open for 120 ms, with blinks layered on top.

Simultaneous blinking and lip-sync animation

Cropping the Face to Fill the Screen

The initial crop prepared for StackChan was a square 240x240 cut around the face, which left 40 px white margins on both sides of the 4:3 display.
To fill the entire screen with the face, I allowed the top of the head and the chin to be cropped out.

I cropped a 4:3 area directly from the 832x1024 source image and scaled it down to 320x240.
Centering the crop on the midpoint between the eyes and mouth, I tested several crop widths on the hardware to find the right balance.

PositionCropResult
Top-leftOriginal 240x240 assetWhite margins on left and right (not tested on hardware)
Top-right440 px width on source imageVerified on hardware; decided eyes and mouth should be slightly larger
Bottom-left340 px width on source imageTop of head and chin mostly cropped out; narrowed slightly more
Bottom-right300 px width on source imageEye region fills screen width edge-to-edge; final choice

Four crop comparisons: top-left is 240x240, top-right is 440px wide, bottom-left is 340px wide, bottom-right is 300px wide

Narrowing the crop width enlarged the facial features accordingly: the eye region expanded from 219x114 px in the 440 px crop to 320x155 px in the 300 px crop.

Splitting Eyes and Mouth into Dedicated Sprites

As with the previous pixel art implementation, PNGs are embedded into flash memory.
At boot, they are unpacked into off-screen canvas buffers (M5Canvas sprites) and pushed to the display via pushSprite().
While the previous 128x128 sprite fit in internal RAM, 320x240 at 16 bits per pixel (2 bytes/pixel) requires 150 KB per frame, so I allocated them in the CoreS3’s 8 MB PSRAM (external RAM).

Storing all 9 combinations (3 eye states x 3 mouth states) as standalone PNGs in flash consumed about 1.07 MB even with the 440 px crop.
To minimize flash footprint against the partition limit (3 MB for this board profile), I separated the images into a single base frame, three eye parts, and three mouth parts instead of storing nine full frames.
After drawing the base frame once at boot, updates only overwrite the rectangular bounding boxes for the eyes and mouth.

The eye and mouth bounding boxes are split so they do not overlap by even a single pixel row.
In the final 300 px crop, the eye region occupies y=0 to 154 (full width of 320 px), while the mouth region occupies y=155 to 239 and x=75 to 244.
Comparing the recomposed images against the fully baked 9 frames, the 440 px version showed differences of up to 21 levels (out of 256).
The discrepancy appeared along the boundary row and was likely due to downsampling interpolation, so I left it as is.

Crop VariantTotal PNG SizeFull Program Size Flashed
440 px, 9 full frames1,068,909 bytesNot flashed
440 px, base + 6 parts272,647 bytes787,135 bytes
300 px, base + 6 parts351,864 bytes894,043 bytes

Unpacking and blitting times were measured on the initial 440 px build (the 300 px build was measured later with the 5-emotion set).

MetricMeasured on 440 px Build
Boot unpacking time249 ms
Free PSRAM after unpacking~7.8 MB
Eye part (219x114 px) blit time~11 ms max

Hardware Blinking Test

Blinking ran continuously in the background, while lip sync started on a screen tap and stopped on another tap.
The video below shows the 300 px build blinking without touching the screen or head touch pad.

Adding Five Emotions

With only a smile available initially, I created four additional emotions: Joy, Anger, Sorrow, and Fun.
Because emotional changes affect the entire face from eyebrows to mouth, I generated edits for each emotion using three seeds starting from the smiling base, and composited the full-face region back onto the smile base as the foundation for each emotion.

EmotionPrompt ContentSeed Used
JoyEyes opened wide and sparkling, closed mouth smiling broadly902
AngerEyebrows furrowed low, glaring forward, closed mouth pouting902
SorrowInner eyebrows raised, slightly teary-eyed, mouth corners downturned902
FunEyes bright and open, grinning with teeth showing901

Because mouth shapes differ across emotions, I generated half-open and wide-open variations for each emotion using three seeds each.

Initially, I tried reusing the closed and half-closed eye diffs from the smiling expression.
I composited only the eye region excluding the eyebrows, but in the angry face the eyebrows were pulled down close to the eyes and fell inside the bounding box, which erased the angry eyebrows during blinks.

I regenerated the closed and half-closed eye diffs for all four emotions with prompt instructions to preserve the eyebrows.
The regenerated closed-eye diffs kept the angry eyebrows intact throughout the blink animation.
Across the emotion bases (12 calls), mouth variations (24 calls), and regenerated eye variations (24 calls), this required 60 API calls.

Three-way comparison of anger blinking: left is base angry face, middle reused smiling closed eyes which erased eyebrows, right is regenerated anger closed eyes

Seam Color Discrepancies Between Eye and Mouth Regions

When reassembling the 9 frames per emotion into base and part sprites, the recomposed frames initially differed from the baked frames by up to 82 levels.
Inspecting the affected pixels showed that the bottom edge of the eye diff and the blur feathering on the mouth diff slightly crossed the y=155 seam line.

By restricting eye diff pixels to strictly above the seam and mouth diff pixels to strictly below it, the maximum discrepancy dropped to 2 to 6 levels across all emotions.

Final Emotion Sprites

With the 320x240 crop matching the hardware display, I assembled eye and mouth diffs for each emotion. From top to bottom: Joy, Anger, Sorrow, and Fun. From left to right: neutral open eyes with closed mouth, half-closed eyes, closed eyes, half-open mouth, and wide-open mouth.

Overview of 4 emotions across 5 states: Joy, Anger, Sorrow, and Fun from top to bottom; open eyes, half-closed, closed, half-open mouth, and wide-open mouth from left to right

Animations showing concurrent blinking and lip sync from top to bottom: Joy, Anger, Sorrow, and Fun.

Joy blinking and lip sync Anger blinking and lip sync Sorrow blinking and lip sync Fun blinking and lip sync

Emotion Switching via Head Touch

On the hardware, tapping the capacitive touch pad on top of the StackChan Body cycles through expressions in sequence: Smile -> Joy -> Anger -> Sorrow -> Fun.
Following the method from the head touch test article, taps are detected by confirming there is no stroking motion across pads and the finger releases within 0.8 seconds.

The initial build crashed and entered a reboot loop immediately upon boot.
The serial monitor reported an exception address near 0x74, pointing to an invalid memory read near a null pointer.
When instantiating the emotion sprites in an array, the target canvas display had not been configured; updating the code to specify the target display in pushSprite() resolved the issue and allowed it to boot normally.

Crop VariantTotal PNG SizeFull Program Size Flashed
300 px, 5 emotions (base + 6 parts each)2,004,583 bytes2,568,959 bytes (81% of the 3 MB app partition)
MetricMeasured on 5-Emotion Build
Boot unpacking time (5 emotions)2,076 ms
Free PSRAM after unpacking5,471 KB
Emotion switch time (redraw base + eyes/mouth)~63 ms
Eye part (320x155 px) blit time~29 ms max

Screen tap lip sync remains active in this build.
The video below shows cycling through all expressions from Smile to Fun via head taps. The mouth remains closed while blinking runs continuously.