m5stack-avatarをpygameで再現すーる

# はじめに
m5stack-avatarをpygameで再現しました！

https://github.com/meganetaaan/m5stack-avatar/blob/master/README_ja.md

https://youtu.be/C1Hj9kfY5qc?si=Tcq_j1i6_3BJ_CCp

# 開発環境
- Windows 11 PC
- Python 3.11

# 導入

~~~Face.py
import pygame
import sys
import math
import random
import numpy as np
import soundcard as sc

import warnings

warnings.filterwarnings("ignore", category=sc.SoundcardRuntimeWarning)

# Initialize Pygame
pygame.init()

# Screen dimensions
screen_width = 1120  # 320
screen_height = 840  # 240

# Colors
COLOR_PRIMARY = (255, 255, 255)  # White
COLOR_BACKGROUND = (0, 0, 0)  # Black

# Create the screen
screen = pygame.display.set_mode((screen_width, screen_height))
pygame.display.set_caption("Face Display")

# Audio setup
CHUNK = 1024
CHANNELS = 1
RATE = 44100

# Mode to control mouth movement
use_speaker_audio = True  # Set to False to use microphone audio

# Drawable classes
class Drawable:
    def draw(self, surface, rect, ctx):
        pass


class Mouth(Drawable):
    def __init__(self, minWidth, maxWidth, minHeight, maxHeight):
        self.minWidth = minWidth
        self.maxWidth = maxWidth
        self.minHeight = minHeight
        self.maxHeight = maxHeight

    def draw(self, surface, rect, ctx):
        primaryColor = COLOR_PRIMARY
        breath = min(1.0, ctx["breath"])
        openRatio = ctx["mouthOpenRatio"]
        h = self.minHeight + (self.maxHeight - self.minHeight) * openRatio
        w = self.minWidth + (self.maxWidth - self.minWidth) * (1 - openRatio)
        x = rect.centerx - w // 2
        y = rect.centery - h // 2 + int(breath * 2)
        pygame.draw.rect(surface, primaryColor, (x, y, w, h))


class Eye(Drawable):
    def __init__(self, size, is_left):
        self.size = size
        self.is_left = is_left

    def draw(self, surface, rect, ctx):
        exp = ctx["expression"]
        x, y = rect.center
        offsetX = ctx["gaze"]["horizontal"] * 3
        offsetY = ctx["gaze"]["vertical"] * 3
        openRatio = ctx["eyeOpenRatio"]
        primaryColor = COLOR_PRIMARY
        backgroundColor = COLOR_BACKGROUND

        if openRatio > 0:
            pygame.draw.circle(
                surface, primaryColor, (x + offsetX, y + offsetY), self.size
            )
            if exp in ["Angry", "Sad"]:
                x0, y0 = x + offsetX - self.size, y + offsetY - self.size
                x1, y1 = x0 + self.size * 2, y0
                x2, y2 = (
                    x0 if (self.is_left != (exp == "Sad")) else x1
                ), y0 + self.size
                pygame.draw.polygon(
                    surface, backgroundColor, [(x0, y0), (x1, y1), (x2, y2)]
                )
            if exp in ["Happy", "Sleepy"]:
                x0, y0 = x + offsetX - self.size, y + offsetY - self.size
                w, h = self.size * 2 + 4, self.size + 2
                if exp == "Happy":
                    y0 += self.size
                    pygame.draw.circle(
                        surface,
                        backgroundColor,
                        (x + offsetX, y + offsetY),
                        int(self.size / 1.5),
                    )
                pygame.draw.rect(surface, backgroundColor, (x0, y0, w, h))
        else:
            x1, y1 = x - self.size + offsetX, y - 2 + offsetY
            w, h = self.size * 2, 4
            pygame.draw.rect(surface, primaryColor, (x1, y1, w, h))


class Eyebrow(Drawable):
    def __init__(self, width, height, is_left):
        self.width = width
        self.height = height
        self.is_left = is_left

    def draw(self, surface, rect, ctx):
        exp = ctx["expression"]
        x, y = rect.centerx, rect.centery
        primaryColor = COLOR_PRIMARY

        if self.width == 0 or self.height == 0:
            return

        if exp in ["Angry", "Sad"]:
            a = -1 if self.is_left ^ (exp == "Sad") else 1
            dx = a * 3
            dy = a * 5
            x1, y1 = x - self.width // 2, y - self.height // 2 - dy
            x2, y2 = x1 - dx, y + self.height // 2 - dy
            x3, y3 = x + self.width // 2 + dx, y - self.height // 2 + dy
            x4, y4 = x + self.width // 2, y + self.height // 2 + dy
            pygame.draw.polygon(surface, primaryColor, [(x1, y1), (x2, y2), (x3, y3)])
            pygame.draw.polygon(surface, primaryColor, [(x2, y2), (x3, y3), (x4, y4)])
        else:
            x1, y1 = x - self.width // 2, y - self.height // 2
            if exp == "Happy":
                y1 -= 5
            pygame.draw.rect(surface, primaryColor, (x1, y1, self.width, self.height))


class BoundingRect(pygame.Rect):
    def setPosition(self, top, left):
        self.top = top
        self.left = left


class Face:
    def __init__(self):
        scale_x = screen_width / 320
        scale_y = screen_height / 240

        self.mouth = Mouth(50 * scale_x, 90 * scale_x, 4 * scale_y, 60 * scale_y)
        self.mouthPos = BoundingRect(
            screen_width * 0.5 - 25 * scale_x,
            screen_height * 0.6,
            50 * scale_x,
            20 * scale_y,
        )  # Adjusted mouth position to center

        self.eyeR = Eye(8 * scale_x, False)
        self.eyeRPos = BoundingRect(
            screen_width * 0.5 - 60 * scale_x - 8 * scale_x,
            screen_height * 0.3875,
            16 * scale_x,
            16 * scale_y,
        )  # Adjusted eye position

        self.eyeL = Eye(8 * scale_x, True)
        self.eyeLPos = BoundingRect(
            screen_width * 0.5 + 60 * scale_x - 8 * scale_x,
            screen_height * 0.3875,
            16 * scale_x,
            16 * scale_y,
        )  # Adjusted eye position to be symmetrical

        self.eyebrowR = Eyebrow(32 * scale_x, 8 * scale_y, False)
        self.eyebrowRPos = BoundingRect(
            screen_width * 0.5 - 60 * scale_x - 16 * scale_x,
            screen_height * 0.2792,
            32 * scale_x,
            8 * scale_y,
        )  # Adjusted eyebrow position

        self.eyebrowL = Eyebrow(32 * scale_x, 8 * scale_y, True)
        self.eyebrowLPos = BoundingRect(
            screen_width * 0.5 + 60 * scale_x - 16 * scale_x,
            screen_height * 0.2792,
            32 * scale_x,
            8 * scale_y,
        )  # Adjusted eyebrow position to be symmetrical

        self.boundingRect = BoundingRect(0, 0, screen_width, screen_height)

    def draw(self, ctx):
        screen.fill(COLOR_BACKGROUND)
        breath = min(1.0, ctx["breath"])

        rect = self.mouthPos.copy()
        rect.setPosition(rect.top + breath * 3, rect.left)
        self.mouth.draw(screen, rect, ctx)

        rect = self.eyeRPos.copy()
        rect.setPosition(rect.top + breath * 3, rect.left)
        self.eyeR.draw(screen, rect, ctx)

        rect = self.eyeLPos.copy()
        rect.setPosition(rect.top + breath * 3, rect.left)
        self.eyeL.draw(screen, rect, ctx)

        # rect = self.eyebrowRPos.copy()
        # rect.setPosition(rect.top + breath * 3, rect.left)
        # self.eyebrowR.draw(screen, rect, ctx)

        # rect = self.eyebrowLPos.copy()
        # rect.setPosition(rect.top + breath * 3, rect.left)
        # self.eyebrowL.draw(screen, rect, ctx)

        pygame.display.flip()


# Context for drawing
ctx = {
    "breath": 0.5,
    "colorDepth": 32,
    "colorPalette": {
        "COLOR_PRIMARY": COLOR_PRIMARY,
        "COLOR_BACKGROUND": COLOR_BACKGROUND,
    },
    "expression": "Neutral",
    "gaze": {"horizontal": 0, "vertical": 0},
    "eyeOpenRatio": 1.0,
    "mouthOpenRatio": 0.5,
}

# Main loop
face = Face()
running = True
fullscreen = False

# Initialize random seed
random.seed()

# Timers for saccade and blink
saccade_interval = 1000
blink_interval = 1000
last_saccade_time = pygame.time.get_ticks()
last_blink_time = pygame.time.get_ticks()
eye_open = True

# List of expressions
expressions = ["Neutral", "Happy", "Sad", "Angry", "Sleepy"]
current_expression_index = 0

def audio_callback(indata, frames, time, status):
    volume_norm = np.linalg.norm(indata) * 10
    ctx["mouthOpenRatio"] = min(1.0, volume_norm / 100)

samplerate = 48000  # サンプリング周波数 [Hz]

mic_id = str(sc.default_speaker().name) if use_speaker_audio else str(sc.default_microphone().name)

with sc.get_microphone(id=mic_id, include_loopback=use_speaker_audio).recorder(samplerate=samplerate) as mic:
    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_f:
                    fullscreen = not fullscreen
                    if fullscreen:
                        screen = pygame.display.set_mode(
                            (screen_width, screen_height), pygame.FULLSCREEN
                        )
                    else:
                        screen = pygame.display.set_mode((screen_width, screen_height))
                elif event.key == pygame.K_SPACE:
                    current_expression_index = (current_expression_index + 1) % len(expressions)
                    ctx["expression"] = expressions[current_expression_index]

        current_time = pygame.time.get_ticks()

        # Saccade logic
        if current_time - last_saccade_time > saccade_interval:
            ctx["gaze"]["horizontal"] = random.uniform(-1, 1)
            ctx["gaze"]["vertical"] = random.uniform(-1, 1)
            saccade_interval = 500 + 100 * random.randint(0, 20)
            last_saccade_time = current_time

        # Blink logic
        if current_time - last_blink_time > blink_interval:
            if eye_open:
                ctx["eyeOpenRatio"] = 1.0
                blink_interval = 2500 + 100 * random.randint(0, 20)
            else:
                ctx["eyeOpenRatio"] = 0.0
                blink_interval = 300 + 10 * random.randint(0, 20)
            eye_open = not eye_open
            last_blink_time = current_time

        # Breath logic
        ctx["breath"] = math.sin(pygame.time.get_ticks() * 2 * math.pi / 2000.0)

        audio_data = mic.record(numframes=CHUNK)
        audio_callback(audio_data, CHUNK, None, None)

        face.draw(ctx)
        pygame.time.delay(33)  # approx. 30fps

pygame.quit()
sys.exit()
~~~

|キー及び設定|機能|
|:--|:--|
|スペースキー|表情を変更（"Neutral", "Happy", "Sad", "Angry", "Sleepy"）|
|Fキー|フルスクリーンモード|
|use_speaker_audio|Trueでスピーカー音、Falseでマイク音を拾ってリップシンク|

# 実行結果

https://x.com/sotongshi/status/1800079946527961274

お疲れさまでした