はじめに
m5stack-avatarをpygameで再現しました!
開発環境
- 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でマイク音を拾ってリップシンク |
実行結果
お疲れさまでした