# エイイチ(ドット絵)が話している間、口と目を動かす部品(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