Bakanayatsu/vrm-pose / vts_playback_driver.py
Bakanayatsu's picture
download
raw
15.2 kB
import os
import json
import asyncio
import numpy as np
import websockets
# Configuration
PORT = 8001
HOST = "ws://127.0.0.1"
NPY_PATH = "dataset\\landmarks\\XTRlxbOA8Pg_12fps_001153_to_end_norm_landmarks.npy"
TARGET_FPS = 12.0
# --- 28-point anime-face landmark map (ground-truthed from visualize_wireframe) ---
# 0 right face @ eye line 4 left face @ eye line
# 1 right face @ chin 3 left face @ chin 2 chin
# 5,6,7 right brow 8,9,10 left brow
# 11,12,13 left eye top (l,c,r) 14,15,16 left eye bottom (l,c,r)
# 17,18,19 right eye top (l,c,r) 20,21,22 right eye bottom (l,c,r)
# 23 nose tip
# 24 right mouth corner 26 left mouth corner 25 mouth top 27 mouth bottom
# (Y axis points down, image coords. "left"/"right" are the subject's, as drawn.)
# Calibration: resting-face baselines and spreads, measured from the 89k-frame
# dataset (percentiles). Each metric is centered on its median so a neutral
# expression maps to a neutral VTS value, then scaled by its spread.
MOUTH_OPEN_NEUTRAL = 0.02 # open_dist / mouth_width, mouth closed
MOUTH_OPEN_FULL = 0.55 # ~p99 → MouthOpen=1.0
SMILE_NEUTRAL = 0.085 # (center_y - corners_y) / mouth_width, neutral
SMILE_UP = 0.185 # spread to MouthSmile=+1
SMILE_DOWN = 0.20 # spread to MouthSmile=-1
YAW_NEUTRAL = 0.267 # (Leye-nose - Reye-nose)/(sum), neutral
YAW_SPREAD = 0.235 # → FaceAngleX=+30
PITCH_NEUTRAL = 0.34 # (eye_y - nose_y)/(eye_y - chin_y), neutral
PITCH_SPREAD = 0.171 # → FaceAngleY=+15 (up)
EYE_OPEN_FULL = 0.15 # eyelid gap / face width → EyeOpen=1.0
EYE_CLOSED = 0.01 # → EyeOpen=0.0
BROWL_NEUTRAL = 0.64 # (eye_y - brow_y)/face_h, neutral
BROWR_NEUTRAL = 0.59
BROW_UP = 0.25 # spread to BrowY=+1
BROW_DOWN = 0.38 # spread to BrowY=-1
def _clip(x, lo, hi):
return max(lo, min(hi, x))
def calculate_params(lm):
"""Map one frame's 28 normalized landmarks to VTube Studio standard inputs.
Returns None if tracking was lost (NaN) so the driver can drop the face."""
if np.isnan(lm).any():
return None
# --- shared reference geometry ---
eye_l = (lm[12] + lm[15]) / 2.0 # left eye center (top-center & bottom-center)
eye_r = (lm[18] + lm[21]) / 2.0 # right eye center
eye_y = (eye_l[1] + eye_r[1]) / 2.0
face_w = np.linalg.norm(lm[0] - lm[4])
mouth_w = np.linalg.norm(lm[24] - lm[26])
# 1. MouthOpen — vertical gap of inner lips, normalized by mouth width.
open_dist = np.linalg.norm(lm[25] - lm[27])
mouth_open = _clip((open_dist / (mouth_w + 1e-6) - MOUTH_OPEN_NEUTRAL) /
(MOUTH_OPEN_FULL - MOUTH_OPEN_NEUTRAL), 0.0, 1.0)
# 2. MouthSmile — corners raised vs mouth center, normalized by mouth width.
corners_y = (lm[24, 1] + lm[26, 1]) / 2.0
center_y = (lm[25, 1] + lm[27, 1]) / 2.0
smile_r = (center_y - corners_y) / (mouth_w + 1e-6) - SMILE_NEUTRAL
if smile_r >= 0:
mouth_smile = _clip(smile_r / SMILE_UP, -1.0, 1.0)
else:
mouth_smile = _clip(smile_r / SMILE_DOWN, -1.0, 1.0)
# 3. Head Roll (FaceAngleZ) — eye-line tilt.
angle_z = np.degrees(np.arctan2(eye_r[1] - eye_l[1], eye_r[0] - eye_l[0]))
# 4. Head Yaw (FaceAngleX) — nose offset between the two eyes.
ld = np.linalg.norm(eye_l - lm[23])
rd = np.linalg.norm(eye_r - lm[23])
yaw = (ld - rd) / (ld + rd + 1e-6)
angle_x = _clip((yaw - YAW_NEUTRAL) / YAW_SPREAD * 30.0, -30.0, 30.0)
# 5. Head Pitch (FaceAngleY) — nose height between eye-line and chin.
nose_y, chin_y = lm[23, 1], lm[2, 1]
pitch_r = (eye_y - nose_y) / (eye_y - chin_y + 1e-6)
angle_y = _clip((pitch_r - PITCH_NEUTRAL) / PITCH_SPREAD * 15.0, -15.0, 15.0)
# 6. Eye openness — eyelid gap per eye, normalized by face width.
l_top = (lm[11] + lm[12] + lm[13]) / 3.0
l_bot = (lm[14] + lm[15] + lm[16]) / 3.0
r_top = (lm[17] + lm[18] + lm[19]) / 3.0
r_bot = (lm[20] + lm[21] + lm[22]) / 3.0
l_open = _clip((np.linalg.norm(l_top - l_bot) / (face_w + 1e-6) - EYE_CLOSED) /
(EYE_OPEN_FULL - EYE_CLOSED), 0.0, 1.0)
r_open = _clip((np.linalg.norm(r_top - r_bot) / (face_w + 1e-6) - EYE_CLOSED) /
(EYE_OPEN_FULL - EYE_CLOSED), 0.0, 1.0)
# 7. Brows — brow height above eye line, normalized by face height.
face_h = abs(chin_y - eye_y) + 1e-6
browL = (eye_y - (lm[5, 1] + lm[6, 1] + lm[7, 1]) / 3.0) / face_h
browR = (eye_y - (lm[8, 1] + lm[9, 1] + lm[10, 1]) / 3.0) / face_h
browL_y = _clip((browL - BROWL_NEUTRAL) /
(BROW_UP if browL >= BROWL_NEUTRAL else BROW_DOWN), -1.0, 1.0)
browR_y = _clip((browR - BROWR_NEUTRAL) /
(BROW_UP if browR >= BROWR_NEUTRAL else BROW_DOWN), -1.0, 1.0)
return {
"MouthOpen": float(mouth_open),
"MouthSmile": float(mouth_smile),
"FaceAngleX": float(angle_x),
"FaceAngleY": float(angle_y),
"FaceAngleZ": float(angle_z),
"EyeOpenLeft": float(l_open),
"EyeOpenRight": float(r_open),
"BrowLeftY": float(browL_y),
"BrowRightY": float(browR_y),
}
SMOOTH_WINDOW = 3 # ponytail: centered moving average over consecutive valid frames;
# tames landmark jitter at 12 FPS without adding latency (offline playback, zero-phase).
# Bump to 5 if still choppy; each step trades responsiveness for smoothness.
def smooth_params(params):
"""Centered moving average over consecutive valid (non-None) frames.
Gaps (None) act as boundaries — never averaged across — so a face-loss
reset still snaps to neutral. Window is odd; edges shrink toward 1.
Only cosmetic params are smoothed; angles/jaw pass through unchanged
to preserve crisp head motion."""
if SMOOTH_WINDOW <= 1:
return params
w = SMOOTH_WINDOW
half = w // 2
SMOOTH_KEYS = ("MouthOpen", "MouthSmile", "EyeOpenLeft", "EyeOpenRight",
"BrowLeftY", "BrowRightY")
out = [None] * len(params)
# Find runs of consecutive valid frames; smooth within each run.
i = 0
n = len(params)
while i < n:
if params[i] is None:
i += 1
continue
j = i
while j < n and params[j] is not None:
j += 1
# run = [i, j)
for k in range(i, j):
lo = max(i, k - half)
hi = min(j, k + half + 1)
avg = {key: 0.0 for key in SMOOTH_KEYS}
for m in range(lo, hi):
pt = params[m]
for key in SMOOTH_KEYS:
avg[key] += pt[key]
cnt = hi - lo
merged = dict(params[k])
for key in SMOOTH_KEYS:
merged[key] = avg[key] / cnt
out[k] = merged
i = j
return out
NEUTRAL_PARAMS = {
"MouthOpen": 0.0, "MouthSmile": 0.0,
"FaceAngleX": 0.0, "FaceAngleY": 0.0, "FaceAngleZ": 0.0,
"EyeOpenLeft": 1.0, "EyeOpenRight": 1.0,
"BrowLeftY": 0.0, "BrowRightY": 0.0,
}
NEUTRAL_PARAMS = {
"MouthOpen": 0.0, "MouthSmile": 0.0,
"FaceAngleX": 0.0, "FaceAngleY": 0.0, "FaceAngleZ": 0.0,
"EyeOpenLeft": 1.0, "EyeOpenRight": 1.0,
"BrowLeftY": 0.0, "BrowRightY": 0.0,
}
class CausalSmoother:
"""Trailing-window moving average for streaming. Same SMOOTH_KEYS as the
offline centered smoother; only difference is causality (uses past frames
only, no future) so it adds zero latency. A None param resets the run so a
face-loss still snaps to neutral unsmoothed."""
SMOOTH_KEYS = ("MouthOpen", "MouthSmile", "EyeOpenLeft", "EyeOpenRight",
"BrowLeftY", "BrowRightY")
def __init__(self, window=SMOOTH_WINDOW):
self.w = max(1, window)
self.buf = [] # recent valid params in the current run
def push(self, p):
if p is None:
self.buf = []
return None
self.buf.append(p)
if len(self.buf) > self.w:
self.buf = self.buf[-self.w:]
merged = dict(p)
for key in self.SMOOTH_KEYS:
merged[key] = sum(b[key] for b in self.buf) / len(self.buf)
return merged
async def vts_connect():
"""Open the websocket, do token handshake + auth + list params. Returns ws
or None on failure."""
print(f"Connecting to VTube Studio at {HOST}:{PORT}...")
ws = await websockets.connect(f"{HOST}:{PORT}")
auth_req = {
"apiName": "VTubeStudioPublicAPI", "apiVersion": "1.0",
"requestID": "auth_token_request", "messageType": "AuthenticationTokenRequest",
"data": {"pluginName": "AnimeLandmarkDriver", "pluginDeveloper": "Dev"}}
await ws.send(json.dumps(auth_req))
resp = json.loads(await ws.recv())
if "authenticationToken" not in resp.get("data", {}):
print("ERROR: Authentication failed or timed out in VTube Studio UI.")
await ws.close(); return None
token = resp["data"]["authenticationToken"]
login_req = {
"apiName": "VTubeStudioPublicAPI", "apiVersion": "1.0",
"requestID": "auth_request", "messageType": "AuthenticationRequest",
"data": {"pluginName": "AnimeLandmarkDriver", "pluginDeveloper": "Dev",
"authenticationToken": token}}
await ws.send(json.dumps(login_req))
login_resp = json.loads(await ws.recv())
if not login_resp["data"]["authenticated"]:
print("Authentication rejected."); await ws.close(); return None
print("Successfully authenticated.")
# List params (informational; not required for injection).
await ws.send(json.dumps({
"apiName": "VTubeStudioPublicAPI", "apiVersion": "1.0",
"requestID": "get_input_params", "messageType": "InputParameterListRequest"}))
input_resp = json.loads(await ws.recv())
if "data" in input_resp:
dp = input_resp["data"].get("defaultParameters", [])
cp = input_resp["data"].get("customParameters", [])
dl = [p.get("name", p.get("id", "?")) for p in dp]
cl = [p.get("name", p.get("id", "?")) for p in cp]
print(f"Default Parameters: {', '.join(dl) if len(dl) <= 60 else ', '.join(dl[:60]) + '...'}")
print(f"Custom Parameters: {', '.join(cl) if cl else 'None'}")
return ws
async def vts_send_frame(ws, p):
"""Inject one param frame (p: dict or None=face lost)."""
if p is None:
data = {"faceFound": False, "mode": "set",
"parameterValues": [{"id": k, "value": v} for k, v in NEUTRAL_PARAMS.items()]}
else:
data = {"faceFound": True, "mode": "set",
"parameterValues": [{"id": k, "value": v} for k, v in p.items()]}
await ws.send(json.dumps({
"apiName": "VTubeStudioPublicAPI", "apiVersion": "1.0",
"requestID": "inject_motion", "messageType": "InjectParameterDataRequest",
"data": data}))
await ws.recv() # Clear WebSocket buffer
async def vts_release(ws):
"""Send one neutral frame so VTS stops holding the last pose."""
await vts_send_frame(ws, None)
# ponytail: the NAT model compresses parameter variance by 3-5x vs real
# landmarks (MSE regression-to-mean on a one-to-many task). Gains restore
# visible dynamic range. Keep at 2x until the mouth-weighted loss (in
# 0.3_train_nat.py) trains a model with natural variance; then drop to 1x.
MODEL_GAIN = {
"MouthOpen": 2.0,
"MouthSmile": 2.0,
"EyeOpenLeft": 2.0,
"EyeOpenRight": 2.0,
"BrowLeftY": 2.0,
"BrowRightY": 2.0,
}
def _apply_gain(p):
"""Amplify compressed params back toward real-landmark variance."""
if p is None:
return None
for key, gain in MODEL_GAIN.items():
if key in p:
p[key] = max(-1.0, min(1.0, p[key] * gain))
return p
async def vts_driver_stream(get_frame, ws=None):
"""Streaming driver. `get_frame` is an awaitable callable returning the next
landmark frame [28,2] or None (face lost); raise StopAsyncError / return a
sentinel to end the stream. Paces output at TARGET_FPS, applies causal
smoothing. This is the real-time path: compute for chunk N overlaps with
playback of chunk N-1.
If `ws` is given (pre-connected), skip the handshake and use it directly.
This lets the caller connect before a user prompt so audio and animation
start truly simultaneously."""
if ws is None:
ws = await vts_connect()
if ws is None:
return
smoother = CausalSmoother(SMOOTH_WINDOW)
delay = 1.0 / TARGET_FPS
loop = asyncio.get_event_loop()
print("Starting motion injection (streaming)...")
try:
# Steady-schedule pacing: target one send every `delay`, NOT `delay -
# get_frame_wait`. The old code counted the queue-wait toward `elapsed`,
# so after a producer stall it skipped the sleep and dumped the next
# frame immediately -- two frames back-to-back (bursts of ~0.2ms gaps),
# which VTS would partially drop ('API sent at the same time').
# Resync (no catchup burst) if we ever fall behind.
next_send = loop.time()
while True:
lm = await get_frame()
if lm is _STREAM_END:
break
p = calculate_params(lm) if (lm is not None and not np.isnan(lm).any()) else None
p = _apply_gain(p)
p = smoother.push(p)
now = loop.time()
if now < next_send:
await asyncio.sleep(next_send - now)
await vts_send_frame(ws, p)
next_send += delay
if next_send < loop.time(): # fell behind: resync, don't pile up
next_send = loop.time()
finally:
await vts_release(ws)
await ws.close()
print("Playback finished.")
_STREAM_END = object() # sentinel: producer signals end-of-stream
async def vts_driver(landmarks=None):
# `landmarks`: either pass a precomputed [V, 28, 2] array OR leave None to
# load the dataset NPY_PATH. Offline path: precompute + centered smoothing.
if landmarks is None:
landmarks = np.load(NPY_PATH)
raw_params = [calculate_params(lm) for lm in landmarks]
raw_params = smooth_params(raw_params)
ws = await vts_connect()
if ws is None:
return
delay = 1.0 / TARGET_FPS
loop = asyncio.get_event_loop()
print("Starting motion injection...")
try:
for p in raw_params:
t0 = loop.time()
await vts_send_frame(ws, p)
elapsed = loop.time() - t0
if elapsed < delay:
await asyncio.sleep(delay - elapsed)
finally:
await vts_release(ws)
await ws.close()
print("Playback finished.")
if __name__ == "__main__":
asyncio.run(vts_driver())

Xet Storage Details

Size:
15.2 kB
·
Xet hash:
5574ee7f727e072409ac0bd8ba0613bd61c4cabbdc51909d074cd9047a38f2ce

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.