209 lines
7.3 KiB
Python
209 lines
7.3 KiB
Python
"""Extra emotion frames for the LED mask, in the same 46x58 display space + RGB
|
|
style as :mod:`colorface` (black bg, cyan eyes, red mouth). These are the
|
|
expression frames Gemini can trigger via ``set_expression`` that the base
|
|
``colorface.default_frames`` does not draw (heart, laugh, love-eyes, cool,
|
|
sleepy, confused, kiss, star-struck).
|
|
|
|
``emotion_frames(...)`` returns ``{name: raw_bytes}`` ready for the mask's DIY
|
|
image upload, exactly like ``colorface.default_frames``. Positions mirror
|
|
``colorface.build_face`` so the eyes/mouth line up with the rest of the set.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import colorface as _cf
|
|
from colorface import DISPLAY_W as W, DISPLAY_H as H, encode
|
|
from PIL import Image, ImageDraw
|
|
|
|
# eye/mouth geometry copied from colorface.build_face so frames are consistent
|
|
_EYE_L = W // 2 - 10 # 13
|
|
_EYE_R = W // 2 + 10 # 33
|
|
_EYE_T, _EYE_B = 15, 29 # normal eye top/bottom
|
|
_EYE_W = 6
|
|
_MOUTH_CY = 44
|
|
_MOUTH_CX = W // 2 # 23
|
|
|
|
|
|
def _canvas():
|
|
img = Image.new("RGB", (W, H), (0, 0, 0))
|
|
return img, ImageDraw.Draw(img)
|
|
|
|
|
|
def _round_eye(g, cx, eye_color, sclera_color, *, t=_EYE_T, b=_EYE_B, w=_EYE_W):
|
|
g.ellipse([cx - w, t, cx + w, b], fill=sclera_color)
|
|
g.ellipse([cx - w + 3, t + 4, cx + w - 3, b - 2], fill=eye_color)
|
|
m = (t + b) // 2
|
|
g.ellipse([cx - 1, m - 1, cx + 1, m + 2], fill=(0, 0, 0))
|
|
|
|
|
|
def _happy_eye(g, cx, color):
|
|
# upward "^"-ish squint (a smiling eye)
|
|
g.arc([cx - 7, _EYE_T - 1, cx + 7, _EYE_B + 5], start=200, end=340,
|
|
fill=color, width=3)
|
|
|
|
|
|
def _heart(g, cx, cy, half, color):
|
|
"""A filled heart centred at (cx, cy), ``half`` = half-width."""
|
|
r = half / 2.0
|
|
g.pieslice([cx - half, cy - r, cx, cy + r], 0, 360, fill=color) # left lobe
|
|
g.pieslice([cx, cy - r, cx + half, cy + r], 0, 360, fill=color) # right lobe
|
|
g.polygon([(cx - half, cy + r * 0.2), (cx + half, cy + r * 0.2),
|
|
(cx, cy + half)], fill=color)
|
|
|
|
|
|
def _star(g, cx, cy, r, color):
|
|
pts = []
|
|
for i in range(10):
|
|
ang = -math.pi / 2 + i * math.pi / 5
|
|
rad = r if i % 2 == 0 else r * 0.45
|
|
pts.append((cx + rad * math.cos(ang), cy + rad * math.sin(ang)))
|
|
g.polygon(pts, fill=color)
|
|
|
|
|
|
def _smile(g, color, *, big=False):
|
|
if big: # open grin
|
|
g.chord([_MOUTH_CX - 13, _MOUTH_CY - 6, _MOUTH_CX + 13, _MOUTH_CY + 12],
|
|
start=0, end=180, fill=color)
|
|
else:
|
|
g.arc([_MOUTH_CX - 12, _MOUTH_CY - 8, _MOUTH_CX + 12, _MOUTH_CY + 8],
|
|
start=20, end=160, fill=color, width=4)
|
|
|
|
|
|
# Fixed emoji colors — these frames are icons, not part of the face's colour
|
|
# scheme, so a heart is always red and a thumb always yellow regardless of the
|
|
# user's chosen eye/mouth colours.
|
|
_RED = (255, 45, 75)
|
|
_PINK = (255, 95, 155)
|
|
_YELLOW = (255, 200, 40)
|
|
|
|
|
|
# -- individual emotion drawings ---------------------------------------------
|
|
|
|
def _heart_face(eye, mouth, sclera):
|
|
img, g = _canvas()
|
|
_heart(g, W // 2, 26, 18, _RED) # one big RED heart fills the face
|
|
return img
|
|
|
|
|
|
def _laugh(eye, mouth, sclera):
|
|
img, g = _canvas()
|
|
_happy_eye(g, _EYE_L, eye)
|
|
_happy_eye(g, _EYE_R, eye)
|
|
_smile(g, mouth, big=True) # wide open grin
|
|
# a joy tear under each eye
|
|
for cx in (_EYE_L, _EYE_R):
|
|
g.ellipse([cx - 2, _EYE_B + 3, cx + 2, _EYE_B + 9], fill=(0, 180, 255))
|
|
return img
|
|
|
|
|
|
def _love(eye, mouth, sclera):
|
|
img, g = _canvas()
|
|
_heart(g, _EYE_L, 22, 8, _PINK) # pink heart-shaped eyes
|
|
_heart(g, _EYE_R, 22, 8, _PINK)
|
|
_smile(g, _PINK)
|
|
return img
|
|
|
|
|
|
def _cool(eye, mouth, sclera):
|
|
img, g = _canvas()
|
|
frame = (40, 40, 55)
|
|
lens = (10, 10, 20)
|
|
# two lenses + bridge (sunglasses)
|
|
for cx in (_EYE_L, _EYE_R):
|
|
g.rounded_rectangle([cx - 8, _EYE_T, cx + 8, _EYE_B + 1], radius=4,
|
|
fill=lens, outline=frame, width=2)
|
|
g.line([cx - 5, _EYE_T + 3, cx + 2, _EYE_T + 3], fill=eye, width=2) # glint
|
|
g.line([_EYE_L + 8, _EYE_T + 3, _EYE_R - 8, _EYE_T + 3], fill=frame, width=3)
|
|
# a cool little smirk (raised on one side)
|
|
g.arc([_MOUTH_CX - 11, _MOUTH_CY - 5, _MOUTH_CX + 12, _MOUTH_CY + 8],
|
|
start=15, end=120, fill=mouth, width=4)
|
|
return img
|
|
|
|
|
|
def _sleepy(eye, mouth, sclera):
|
|
img, g = _canvas()
|
|
# droopy half-closed eyes: lid arc over a thin slit
|
|
for cx in (_EYE_L, _EYE_R):
|
|
g.arc([cx - 7, _EYE_T + 2, cx + 7, _EYE_B + 4], start=160, end=20,
|
|
fill=eye, width=3)
|
|
# small tired mouth
|
|
g.ellipse([_MOUTH_CX - 4, _MOUTH_CY - 2, _MOUTH_CX + 4, _MOUTH_CY + 4], fill=mouth)
|
|
# zZ drawn as cheap line-glyphs (no font dependency)
|
|
for (x, y, s) in ((36, 8, 5), (41, 3, 3)):
|
|
g.line([x, y, x + s, y], fill=eye, width=1)
|
|
g.line([x + s, y, x, y + s], fill=eye, width=1)
|
|
g.line([x, y + s, x + s, y + s], fill=eye, width=1)
|
|
return img
|
|
|
|
|
|
def _confused(eye, mouth, sclera):
|
|
img, g = _canvas()
|
|
_round_eye(g, _EYE_L, eye, sclera) # normal eye
|
|
_round_eye(g, _EYE_R, eye, sclera, t=_EYE_T - 3, b=_EYE_B - 3, w=5) # raised/small
|
|
# raised brow over the small eye
|
|
g.line([_EYE_R - 6, _EYE_T - 6, _EYE_R + 6, _EYE_T - 9], fill=eye, width=2)
|
|
# wavy/squiggle mouth
|
|
pts = [(_MOUTH_CX - 12 + i * 4, _MOUTH_CY + (3 if i % 2 else -3)) for i in range(7)]
|
|
g.line(pts, fill=mouth, width=3, joint="curve")
|
|
return img
|
|
|
|
|
|
def _kiss(eye, mouth, sclera):
|
|
img, g = _canvas()
|
|
_round_eye(g, _EYE_L, eye, sclera)
|
|
g.line([_EYE_R - 6, (_EYE_T + _EYE_B) // 2, _EYE_R + 6, (_EYE_T + _EYE_B) // 2],
|
|
fill=eye, width=3) # winking eye
|
|
# puckered red lips
|
|
g.ellipse([_MOUTH_CX - 4, _MOUTH_CY - 4, _MOUTH_CX + 4, _MOUTH_CY + 5], fill=_RED)
|
|
g.ellipse([_MOUTH_CX - 2, _MOUTH_CY - 2, _MOUTH_CX + 2, _MOUTH_CY + 3], fill=(0, 0, 0))
|
|
_heart(g, 37, 13, 6, _RED) # little floating red heart
|
|
return img
|
|
|
|
|
|
def _star_struck(eye, mouth, sclera):
|
|
img, g = _canvas()
|
|
_star(g, _EYE_L, 22, 7, (255, 220, 0))
|
|
_star(g, _EYE_R, 22, 7, (255, 220, 0))
|
|
_smile(g, mouth, big=True)
|
|
return img
|
|
|
|
|
|
def _thumbs_up(eye, mouth, sclera):
|
|
# a 👍: one bold vertical thumb + a bold fist block, kept simple so it reads
|
|
# on the low-res LED grid (fine detail just blurs into a blob).
|
|
img, g = _canvas()
|
|
g.rounded_rectangle([11, 30, 37, 52], radius=8, fill=_YELLOW) # fist block
|
|
g.rounded_rectangle([13, 6, 29, 34], radius=8, fill=_YELLOW) # big thumb up
|
|
g.line([30, 34, 36, 34], fill=(0, 0, 0), width=3) # thumb/finger split
|
|
return img
|
|
|
|
|
|
_BUILDERS = {
|
|
"heart": _heart_face,
|
|
"laugh": _laugh,
|
|
"love": _love,
|
|
"cool": _cool,
|
|
"sleepy": _sleepy,
|
|
"confused": _confused,
|
|
"kiss": _kiss,
|
|
"star_struck": _star_struck,
|
|
"thumbs_up": _thumbs_up,
|
|
}
|
|
|
|
|
|
def emotion_frames(*, eye_color=_cf.DEFAULT_EYE, mouth_color=_cf.DEFAULT_MOUTH,
|
|
sclera_color=_cf.WHITE, include=None) -> dict:
|
|
"""Return ``{name: raw_bytes}`` for the extra emotion frames.
|
|
|
|
``include`` optionally restricts to a subset (a set/list of names) so the
|
|
caller can honour the mask's slot budget.
|
|
"""
|
|
names = list(_BUILDERS) if include is None else [n for n in _BUILDERS if n in include]
|
|
out = {}
|
|
for name in names:
|
|
img = _BUILDERS[name](eye_color, mouth_color, sclera_color)
|
|
out[name] = encode(img)
|
|
return out
|