# エイイチ(ドット絵)が話している間、口と目を動かす部品(2026-10-28 エイイチの指示「話している時はおれの口や目が動くように」)
# 口は3段(閉じる・いつもの笑顔・大きく開く)を、声の大きさで 0.1 秒ごとに選ぶ(1段ずつしか変えない)。
# 目は、話し始めと、話している間ときどきまばたきする。
# どれも 100 ドットの絵の「その部分だけ」を描いた透明な PNG を、時間で重ねていく(motion_kit の data-a="fade")。
import os, io, base64, wave
import numpy as np
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
INK = (1, 1, 1, 255)
TEETH = (250, 250, 246, 255)
INSIDE = (70, 22, 18, 255)
TONGUE = (177, 67, 33, 255)
# 口の両端(左x, 右x, 上y)。100 ドットの絵の座標
MOUTH = {'udegumi': (39, 52, 41), 'yubisasu': (39, 52, 41), 'unazuku': (36, 47, 43), 'hirameku': (38, 49, 42)}
def _load(pose, b=False):
return np.array(Image.open(os.path.join(HERE, f"src/ei/{pose}{'_b' if b else ''}.png")).convert('RGBA'))
def fill_teeth(a):
"""顔の中の透明な穴(歯の所)を白で埋める。背景に映像があると透けて見えるため"""
from scipy import ndimage
t = a[..., 3] < 128
lab, n = ndimage.label(t)
edge = set(np.unique(np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]])))
for k in range(1, n + 1):
if k in edge:
continue
m = lab == k
if m.sum() < 80:
a[m] = TEETH
return a
def _uri(a):
b = io.BytesIO(); Image.fromarray(a.astype(np.uint8)).save(b, 'PNG')
return 'data:image/png;base64,' + base64.b64encode(b.getvalue()).decode()
def _skin(a, xl, xr, yt):
"""顔のいちばん多い肌の色(口のまわりの、明るい色)"""
from collections import Counter
reg = a[yt - 6:yt + 6, xl - 3:xr + 4].reshape(-1, 4)
c = Counter(tuple(int(v) for v in px) for px in reg if px[3] > 200 and px[0] > 220 and 120 < px[1] < 200)
return c.most_common(1)[0][0]
def mouth_patches(pose):
"""口だけを描いた透明な絵 3枚(0 閉じる・1 いつもの・2 開く)"""
a = fill_teeth(_load(pose))
xl, xr, yt = MOUTH[pose]
sk = _skin(a, xl, xr, yt)
y0, y1 = yt, yt + 5
out = []
for lv in range(3):
p = np.zeros_like(a)
if lv == 1:
p[y0:y1 + 1, xl:xr + 1] = a[y0:y1 + 1, xl:xr + 1]
else:
# 元の口の線と歯だけを肌の色で消す(まわりの影は残す)
reg = a[y0:y1 + 1, xl:xr + 1].copy()
rg = reg[..., :3].astype(int)
ink = (rg.max(2) < 90) | (abs(rg[..., 0] - rg[..., 1]) < 40) # 黒い線・白や灰の歯
reg[ink] = sk
p[y0:y1 + 1, xl:xr + 1] = reg
p[yt, xl] = p[yt, xr] = INK
p[yt + 1, xl + 1:xr] = INK
if lv == 2:
p[yt + 2, xl + 1] = p[yt + 2, xr - 1] = INK
p[yt + 2, xl + 2:xr - 1] = TEETH
p[yt + 3, xl + 2] = p[yt + 3, xr - 2] = INK
p[yt + 3, xl + 3:xr - 2] = INSIDE
p[yt + 4, xl + 3] = p[yt + 4, xr - 3] = INK
p[yt + 4, xl + 4:xr - 3] = TONGUE
p[yt + 5, xl + 4:xr - 3] = INK
out.append(_uri(p))
return out
def eye_patches(pose):
"""目の所だけの絵(開いた目・閉じた目)"""
o, c = _load(pose), _load(pose, True)
d = np.argwhere((o != c).any(2))
if not len(d):
return None
y0, y1, x0, x1 = d[:, 0].min() - 1, d[:, 0].max() + 1, d[:, 1].min() - 1, d[:, 1].max() + 1
po, pc = np.zeros_like(o), np.zeros_like(o)
po[y0:y1 + 1, x0:x1 + 1] = o[y0:y1 + 1, x0:x1 + 1]
pc[y0:y1 + 1, x0:x1 + 1] = c[y0:y1 + 1, x0:x1 + 1]
return _uri(po), _uri(pc)
def base_uri(pose, b=False):
return _uri(fill_teeth(_load(pose, b)))
def envelope(i, step=0.1):
"""tts/{i:03d}.wav の声の大きさ(step 秒ごと・0〜1)"""
w = wave.open(os.path.join(HERE, f'tts/{i:03d}.wav'))
sr, n, ch, sw = w.getframerate(), w.getnframes(), w.getnchannels(), w.getsampwidth()
x = np.frombuffer(w.readframes(n), dtype=np.int16 if sw == 2 else np.int32).astype(np.float32)
if ch > 1:
x = x.reshape(-1, ch).mean(1)
x /= np.abs(x).max() + 1e-9
h = int(sr * step)
r = np.array([np.sqrt(np.mean(x[k:k + h] ** 2)) for k in range(0, len(x) - h, h)])
return r / (np.percentile(r, 95) + 1e-9)
def talk_layers(pose, i, t0, step=0.1):
"""場面の頭から t0 秒にエイイチの文 i が始まるときの、口と目の重ね(img の並び)"""
m = mouth_patches(pose)
env = envelope(i, step)
seq, lv = [], 1
for k, v in enumerate(env):
want = 0 if v < 0.18 else (1 if v < 0.55 else 2)
if want != lv:
lv += 1 if want > lv else -1
seq.append((round(t0 + k * step, 2), lv))
end = round(t0 + len(env) * step, 2)
seq.append((end, 1)) # 話し終わったら、いつもの笑顔に戻す
h = ''
for t, l in seq:
h += f'
'
e = eye_patches(pose)
if e:
o, c = e
bt, last = t0 + 0.05, end
while bt < last - 0.3:
h += f'
'
bt += 1.6 + (int(bt * 7) % 5) * 0.25
return h, (t0, end)
from functools import lru_cache
mouth_patches = lru_cache(None)(mouth_patches)
eye_patches = lru_cache(None)(eye_patches)
base_uri = lru_cache(None)(base_uri)
def mouth_seq(i, t0, step=0.1):
"""口の段の切り替え [(秒, 段)] と、話し終わりの秒"""
env = envelope(i, step)
seq, lv = [], 1
for k, v in enumerate(env):
want = 0 if v < 0.18 else (1 if v < 0.55 else 2)
if want != lv:
lv += 1 if want > lv else -1
seq.append((round(t0 + k * step, 2), lv))
end = round(t0 + len(env) * step, 2)
seq.append((end, 1))
return seq, end
def blink_times(t0, end):
out, bt = [], t0 + 0.05
while bt < end - 0.3:
out.append(round(bt, 2)); bt += 1.6 + (int(bt * 7) % 5) * 0.25
return out