Fix live camera gorilla avatar preset

这个提交包含在:
cryptocommuniums-afk
2026-03-15 21:03:06 +08:00
父节点 264d49475b
当前提交 139dc61b61
修改 5 个文件,包含 907 行新增117 行删除

查看文件

@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
ACTION_WINDOW_FRAMES,
createStableActionState,
getAvatarAnchors,
resolveAvatarKeyFromPrompt,
stabilizeActionStream,
type FrameActionSample,
} from "./liveCamera";
function feedSamples(samples: Array<Omit<FrameActionSample, "timestamp">>, intervalMs = 33) {
const history: FrameActionSample[] = [];
const state = createStableActionState();
let lastResult = null as ReturnType<typeof stabilizeActionStream> | null;
samples.forEach((sample, index) => {
lastResult = stabilizeActionStream(
{
...sample,
timestamp: index * intervalMs,
},
history,
state,
);
});
return { history, state, lastResult };
}
describe("live camera action stabilizer", () => {
it("locks a dominant action after a full temporal window", () => {
const samples = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "forehand" as const,
confidence: 0.84,
}));
const { lastResult } = feedSamples(samples);
expect(lastResult?.stableAction).toBe("forehand");
expect(lastResult?.windowAction).toBe("forehand");
expect(lastResult?.pending).toBe(false);
expect(lastResult?.windowShare).toBeGreaterThan(0.9);
});
it("ignores brief action spikes and keeps the stable action", () => {
const stableFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "forehand" as const,
confidence: 0.82,
}));
const noisyFrames = Array.from({ length: 5 }, () => ({
action: "backhand" as const,
confidence: 0.88,
}));
const { lastResult } = feedSamples([...stableFrames, ...noisyFrames]);
expect(lastResult?.stableAction).toBe("forehand");
expect(lastResult?.pending).toBe(false);
});
it("switches only after the next action persists long enough", () => {
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "forehand" as const,
confidence: 0.8,
}));
const backhandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "backhand" as const,
confidence: 0.85,
}));
const { lastResult, state } = feedSamples([...forehandFrames, ...backhandFrames]);
expect(lastResult?.stableAction).toBe("backhand");
expect(state.switchCount).toBeGreaterThanOrEqual(2);
});
it("requires a longer delay before falling back to unknown", () => {
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "forehand" as const,
confidence: 0.83,
}));
const unknownFrames = Array.from({ length: 10 }, () => ({
action: "unknown" as const,
confidence: 0.4,
}));
const { lastResult } = feedSamples([...forehandFrames, ...unknownFrames]);
expect(lastResult?.stableAction).toBe("forehand");
});
});
describe("live camera avatar helpers", () => {
it("maps prompt keywords into avatar presets", () => {
expect(resolveAvatarKeyFromPrompt("切换成猩猩形象", "gorilla")).toBe("gorilla");
expect(resolveAvatarKeyFromPrompt("dog mascot", "gorilla")).toBe("dog");
expect(resolveAvatarKeyFromPrompt("", "pig")).toBe("pig");
});
it("builds avatar anchors from pose landmarks", () => {
const landmarks = Array.from({ length: 33 }, () => ({ x: 0.5, y: 0.5, visibility: 0.95 }));
landmarks[0] = { x: 0.5, y: 0.16, visibility: 0.99 };
landmarks[11] = { x: 0.4, y: 0.3, visibility: 0.99 };
landmarks[12] = { x: 0.6, y: 0.3, visibility: 0.99 };
landmarks[15] = { x: 0.28, y: 0.44, visibility: 0.99 };
landmarks[16] = { x: 0.72, y: 0.44, visibility: 0.99 };
landmarks[23] = { x: 0.44, y: 0.58, visibility: 0.99 };
landmarks[24] = { x: 0.56, y: 0.58, visibility: 0.99 };
landmarks[27] = { x: 0.43, y: 0.92, visibility: 0.99 };
landmarks[28] = { x: 0.57, y: 0.92, visibility: 0.99 };
const anchors = getAvatarAnchors(landmarks, 1280, 720);
expect(anchors).not.toBeNull();
expect(anchors?.headRadius).toBeGreaterThan(30);
expect(anchors?.bodyHeight).toBeGreaterThan(120);
expect(anchors?.rightHandX).toBeGreaterThan(anchors?.leftHandX || 0);
});
});

514
client/src/lib/liveCamera.ts 普通文件
查看文件

@@ -0,0 +1,514 @@
export type LiveActionType = "forehand" | "backhand" | "serve" | "volley" | "overhead" | "slice" | "lob" | "unknown";
export type PosePoint = {
x: number;
y: number;
visibility?: number;
};
export type AvatarKey = "gorilla" | "monkey" | "pig" | "dog";
export type AvatarRenderState = {
enabled: boolean;
avatarKey: AvatarKey;
customLabel?: string;
};
export type FrameActionSample = {
action: LiveActionType;
confidence: number;
timestamp: number;
};
export type StableActionState = {
current: LiveActionType;
currentSince: number | null;
candidate: LiveActionType | null;
candidateSince: number | null;
candidateWindows: number;
switchCount: number;
};
export type StabilizedActionMeta = {
stableAction: LiveActionType;
stableConfidence: number;
windowAction: LiveActionType;
windowConfidence: number;
windowShare: number;
windowFrames: number;
windowProgress: number;
pending: boolean;
pendingAction: LiveActionType | null;
stableMs: number;
candidateMs: number;
rawVolatility: number;
switchCount: number;
};
type ActionStat = {
count: number;
totalConfidence: number;
share: number;
averageConfidence: number;
strength: number;
};
type AvatarAnchors = {
headX: number;
headY: number;
headRadius: number;
bodyX: number;
bodyY: number;
bodyWidth: number;
bodyHeight: number;
shoulderY: number;
footY: number;
leftHandX: number;
leftHandY: number;
rightHandX: number;
rightHandY: number;
};
const ACTIONS: LiveActionType[] = ["forehand", "backhand", "serve", "volley", "overhead", "slice", "lob", "unknown"];
export const ACTION_WINDOW_FRAMES = 24;
const ACTION_WINDOW_MIN_SHARE = 0.6;
const ACTION_WINDOW_MIN_CONFIDENCE = 0.58;
const ACTION_SWITCH_MIN_MS = 700;
const ACTION_UNKNOWN_MIN_MS = 900;
const ACTION_LOCK_IN_WINDOWS = 2;
const ACTION_SWITCH_DELTA = 0.12;
export const AVATAR_PRESETS: Array<{ key: AvatarKey; label: string; keywords: string[] }> = [
{ key: "gorilla", label: "猩猩", keywords: ["gorilla", "ape", "猩猩", "猩", "大猩猩"] },
{ key: "monkey", label: "猴子", keywords: ["monkey", "ape", "猴", "猴子"] },
{ key: "pig", label: "猪", keywords: ["pig", "猪", "小猪"] },
{ key: "dog", label: "狗", keywords: ["dog", "puppy", "犬", "狗", "小狗"] },
];
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function getActionStat(samples: FrameActionSample[], action: LiveActionType): ActionStat {
const matches = samples.filter((sample) => sample.action === action);
const count = matches.length;
const totalConfidence = matches.reduce((sum, sample) => sum + sample.confidence, 0);
const share = samples.length > 0 ? count / samples.length : 0;
const averageConfidence = count > 0 ? totalConfidence / count : 0;
return {
count,
totalConfidence,
share,
averageConfidence,
strength: share * 0.7 + averageConfidence * 0.3,
};
}
function getWindowAction(samples: FrameActionSample[]) {
const stats = new Map<LiveActionType, ActionStat>();
ACTIONS.forEach((action) => {
stats.set(action, getActionStat(samples, action));
});
const ranked = ACTIONS
.map((action) => ({ action, stats: stats.get(action)! }))
.sort((a, b) => {
if (b.stats.strength !== a.stats.strength) {
return b.stats.strength - a.stats.strength;
}
return b.stats.totalConfidence - a.stats.totalConfidence;
});
const winner = ranked[0] ?? { action: "unknown" as LiveActionType, stats: stats.get("unknown")! };
const qualifies =
winner.stats.share >= ACTION_WINDOW_MIN_SHARE &&
winner.stats.averageConfidence >= ACTION_WINDOW_MIN_CONFIDENCE;
return {
action: qualifies ? winner.action : "unknown",
stats,
winnerStats: winner.stats,
};
}
function getRawVolatility(samples: FrameActionSample[]) {
if (samples.length <= 1) return 0;
let switches = 0;
for (let index = 1; index < samples.length; index += 1) {
if (samples[index]?.action !== samples[index - 1]?.action) {
switches += 1;
}
}
return switches / (samples.length - 1);
}
export function createStableActionState(initial: LiveActionType = "unknown"): StableActionState {
return {
current: initial,
currentSince: null,
candidate: null,
candidateSince: null,
candidateWindows: 0,
switchCount: 0,
};
}
export function createEmptyStabilizedActionMeta(): StabilizedActionMeta {
return {
stableAction: "unknown",
stableConfidence: 0,
windowAction: "unknown",
windowConfidence: 0,
windowShare: 0,
windowFrames: 0,
windowProgress: 0,
pending: false,
pendingAction: null,
stableMs: 0,
candidateMs: 0,
rawVolatility: 0,
switchCount: 0,
};
}
export function stabilizeActionStream(
sample: FrameActionSample,
history: FrameActionSample[],
state: StableActionState,
) {
history.push(sample);
if (history.length > ACTION_WINDOW_FRAMES) {
history.splice(0, history.length - ACTION_WINDOW_FRAMES);
}
const { action: windowAction, stats } = getWindowAction(history);
const windowStats = stats.get(windowAction) ?? getActionStat(history, "unknown");
const currentStats = stats.get(state.current) ?? getActionStat(history, state.current);
const pendingMinMs = windowAction === "unknown" ? ACTION_UNKNOWN_MIN_MS : ACTION_SWITCH_MIN_MS;
const windowProgress = clamp(history.length / ACTION_WINDOW_FRAMES, 0, 1);
if (state.currentSince == null) {
state.currentSince = sample.timestamp;
}
if (windowAction === state.current) {
state.candidate = null;
state.candidateSince = null;
state.candidateWindows = 0;
} else if (windowProgress >= 0.7) {
if (state.candidate !== windowAction) {
state.candidate = windowAction;
state.candidateSince = sample.timestamp;
state.candidateWindows = 1;
} else {
state.candidateWindows += 1;
}
const candidateStats = stats.get(windowAction) ?? getActionStat(history, windowAction);
const currentStrength = state.current === "unknown" ? currentStats.strength * 0.55 : currentStats.strength;
const candidateDuration = state.candidateSince == null ? 0 : sample.timestamp - state.candidateSince;
const canSwitch =
state.candidateWindows >= ACTION_LOCK_IN_WINDOWS &&
candidateDuration >= pendingMinMs &&
candidateStats.strength >= currentStrength + ACTION_SWITCH_DELTA;
if (canSwitch) {
state.current = windowAction;
state.currentSince = sample.timestamp;
state.candidate = null;
state.candidateSince = null;
state.candidateWindows = 0;
state.switchCount += 1;
}
}
const stableStats = stats.get(state.current) ?? getActionStat(history, state.current);
const stableConfidence = state.current === "unknown"
? Math.max(sample.confidence * 0.45, stableStats.averageConfidence)
: Math.max(stableStats.averageConfidence, windowStats.averageConfidence * 0.88);
return {
stableAction: state.current,
stableConfidence: clamp(stableConfidence, 0, 1),
windowAction,
windowConfidence: clamp(windowStats.averageConfidence, 0, 1),
windowShare: clamp(windowStats.share, 0, 1),
windowFrames: history.length,
windowProgress,
pending: Boolean(state.candidate),
pendingAction: state.candidate,
stableMs: state.currentSince == null ? 0 : sample.timestamp - state.currentSince,
candidateMs: state.candidateSince == null ? 0 : sample.timestamp - state.candidateSince,
rawVolatility: getRawVolatility(history),
switchCount: state.switchCount,
} satisfies StabilizedActionMeta;
}
export function resolveAvatarKeyFromPrompt(prompt: string, fallback: AvatarKey): AvatarKey {
const normalized = prompt.trim().toLowerCase();
if (!normalized) return fallback;
const matched = AVATAR_PRESETS.find((preset) => preset.keywords.some((keyword) => normalized.includes(keyword)));
return matched?.key ?? fallback;
}
function averagePoint(a: PosePoint | undefined, b: PosePoint | undefined, defaultX: number, defaultY: number) {
return {
x: ((a?.x ?? defaultX) + (b?.x ?? defaultX)) / 2,
y: ((a?.y ?? defaultY) + (b?.y ?? defaultY)) / 2,
};
}
export function getAvatarAnchors(landmarks: PosePoint[], width: number, height: number): AvatarAnchors | null {
const nose = landmarks[0];
const leftShoulder = landmarks[11];
const rightShoulder = landmarks[12];
const leftHip = landmarks[23];
const rightHip = landmarks[24];
const leftWrist = landmarks[15];
const rightWrist = landmarks[16];
const leftAnkle = landmarks[27];
const rightAnkle = landmarks[28];
const leftEar = landmarks[7];
const rightEar = landmarks[8];
if (!nose || !leftShoulder || !rightShoulder || !leftHip || !rightHip) {
return null;
}
const shoulderCenter = averagePoint(leftShoulder, rightShoulder, 0.5, 0.32);
const hipCenter = averagePoint(leftHip, rightHip, 0.5, 0.62);
const ankleCenter = averagePoint(leftAnkle, rightAnkle, hipCenter.x, 0.92);
const shoulderSpan = Math.abs(rightShoulder.x - leftShoulder.x) * width;
const torsoHeight = Math.max((hipCenter.y - shoulderCenter.y) * height, shoulderSpan * 0.8);
const headRadius = Math.max(
shoulderSpan * 0.28,
Math.abs((leftEar?.x ?? nose.x - 0.04) - (rightEar?.x ?? nose.x + 0.04)) * width * 0.45,
34,
);
const bodyWidth = Math.max(shoulderSpan * 1.05, headRadius * 1.8);
const bodyHeight = Math.max(torsoHeight * 1.1, headRadius * 2.2);
return {
headX: nose.x * width,
headY: Math.min(nose.y * height, shoulderCenter.y * height - headRadius * 0.2),
headRadius,
bodyX: shoulderCenter.x * width,
bodyY: shoulderCenter.y * height + bodyHeight * 0.48,
bodyWidth,
bodyHeight,
shoulderY: shoulderCenter.y * height,
footY: Math.max(ankleCenter.y * height, hipCenter.y * height + bodyHeight * 1.35),
leftHandX: (leftWrist?.x ?? leftShoulder.x - 0.08) * width,
leftHandY: (leftWrist?.y ?? shoulderCenter.y + 0.1) * height,
rightHandX: (rightWrist?.x ?? rightShoulder.x + 0.08) * width,
rightHandY: (rightWrist?.y ?? shoulderCenter.y + 0.1) * height,
};
}
function drawRoundedBody(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, fill: string) {
const radius = Math.min(anchors.bodyWidth, anchors.bodyHeight) * 0.18;
const left = anchors.bodyX - anchors.bodyWidth / 2;
const top = anchors.bodyY - anchors.bodyHeight / 2;
const right = left + anchors.bodyWidth;
const bottom = top + anchors.bodyHeight;
ctx.beginPath();
ctx.moveTo(left + radius, top);
ctx.lineTo(right - radius, top);
ctx.quadraticCurveTo(right, top, right, top + radius);
ctx.lineTo(right, bottom - radius);
ctx.quadraticCurveTo(right, bottom, right - radius, bottom);
ctx.lineTo(left + radius, bottom);
ctx.quadraticCurveTo(left, bottom, left, bottom - radius);
ctx.lineTo(left, top + radius);
ctx.quadraticCurveTo(left, top, left + radius, top);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
}
function drawLimbs(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, stroke: string) {
ctx.strokeStyle = stroke;
ctx.lineWidth = Math.max(anchors.headRadius * 0.22, 10);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(anchors.bodyX - anchors.bodyWidth * 0.24, anchors.shoulderY + anchors.headRadius * 0.65);
ctx.lineTo(anchors.leftHandX, anchors.leftHandY);
ctx.moveTo(anchors.bodyX + anchors.bodyWidth * 0.24, anchors.shoulderY + anchors.headRadius * 0.65);
ctx.lineTo(anchors.rightHandX, anchors.rightHandY);
ctx.moveTo(anchors.bodyX - anchors.bodyWidth * 0.14, anchors.bodyY + anchors.bodyHeight * 0.42);
ctx.lineTo(anchors.bodyX - anchors.bodyWidth * 0.18, anchors.footY);
ctx.moveTo(anchors.bodyX + anchors.bodyWidth * 0.14, anchors.bodyY + anchors.bodyHeight * 0.42);
ctx.lineTo(anchors.bodyX + anchors.bodyWidth * 0.18, anchors.footY);
ctx.stroke();
}
function drawGorillaAvatar(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors) {
ctx.fillStyle = "#3f3f46";
ctx.beginPath();
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(anchors.headX - anchors.headRadius * 0.78, anchors.headY - anchors.headRadius * 0.1, anchors.headRadius * 0.28, 0, Math.PI * 2);
ctx.arc(anchors.headX + anchors.headRadius * 0.78, anchors.headY - anchors.headRadius * 0.1, anchors.headRadius * 0.28, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#d6d3d1";
ctx.beginPath();
ctx.ellipse(anchors.headX, anchors.headY + anchors.headRadius * 0.16, anchors.headRadius * 0.54, anchors.headRadius * 0.46, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#111827";
ctx.beginPath();
ctx.arc(anchors.headX - anchors.headRadius * 0.24, anchors.headY - anchors.headRadius * 0.12, anchors.headRadius * 0.08, 0, Math.PI * 2);
ctx.arc(anchors.headX + anchors.headRadius * 0.24, anchors.headY - anchors.headRadius * 0.12, anchors.headRadius * 0.08, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(anchors.headX, anchors.headY + anchors.headRadius * 0.06, anchors.headRadius * 0.08, 0, Math.PI * 2);
ctx.fill();
drawRoundedBody(ctx, anchors, "rgba(39,39,42,0.95)");
drawLimbs(ctx, anchors, "rgba(63,63,70,0.92)");
}
function drawMonkeyAvatar(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors) {
ctx.fillStyle = "#8b5a3c";
ctx.beginPath();
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(anchors.headX - anchors.headRadius * 0.82, anchors.headY - anchors.headRadius * 0.16, anchors.headRadius * 0.34, 0, Math.PI * 2);
ctx.arc(anchors.headX + anchors.headRadius * 0.82, anchors.headY - anchors.headRadius * 0.16, anchors.headRadius * 0.34, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#f3d7bf";
ctx.beginPath();
ctx.ellipse(anchors.headX, anchors.headY + anchors.headRadius * 0.14, anchors.headRadius * 0.56, anchors.headRadius * 0.5, 0, 0, Math.PI * 2);
ctx.fill();
drawRoundedBody(ctx, anchors, "rgba(120,53,15,0.95)");
drawLimbs(ctx, anchors, "rgba(146,64,14,0.9)");
}
function drawPigAvatar(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors) {
ctx.fillStyle = "#f9a8d4";
ctx.beginPath();
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.moveTo(anchors.headX - anchors.headRadius * 0.62, anchors.headY - anchors.headRadius * 0.42);
ctx.lineTo(anchors.headX - anchors.headRadius * 0.18, anchors.headY - anchors.headRadius * 1.06);
ctx.lineTo(anchors.headX - anchors.headRadius * 0.02, anchors.headY - anchors.headRadius * 0.32);
ctx.closePath();
ctx.moveTo(anchors.headX + anchors.headRadius * 0.62, anchors.headY - anchors.headRadius * 0.42);
ctx.lineTo(anchors.headX + anchors.headRadius * 0.18, anchors.headY - anchors.headRadius * 1.06);
ctx.lineTo(anchors.headX + anchors.headRadius * 0.02, anchors.headY - anchors.headRadius * 0.32);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#fbcfe8";
ctx.beginPath();
ctx.ellipse(anchors.headX, anchors.headY + anchors.headRadius * 0.18, anchors.headRadius * 0.44, anchors.headRadius * 0.28, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#be185d";
ctx.beginPath();
ctx.arc(anchors.headX - anchors.headRadius * 0.14, anchors.headY + anchors.headRadius * 0.18, anchors.headRadius * 0.06, 0, Math.PI * 2);
ctx.arc(anchors.headX + anchors.headRadius * 0.14, anchors.headY + anchors.headRadius * 0.18, anchors.headRadius * 0.06, 0, Math.PI * 2);
ctx.fill();
drawRoundedBody(ctx, anchors, "rgba(244,114,182,0.92)");
drawLimbs(ctx, anchors, "rgba(244,114,182,0.86)");
}
function drawDogAvatar(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors) {
ctx.fillStyle = "#d4a373";
ctx.beginPath();
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(anchors.headX - anchors.headRadius * 0.72, anchors.headY - anchors.headRadius * 0.28, anchors.headRadius * 0.22, anchors.headRadius * 0.46, Math.PI / 4, 0, Math.PI * 2);
ctx.ellipse(anchors.headX + anchors.headRadius * 0.72, anchors.headY - anchors.headRadius * 0.28, anchors.headRadius * 0.22, anchors.headRadius * 0.46, -Math.PI / 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#f5e6d3";
ctx.beginPath();
ctx.ellipse(anchors.headX, anchors.headY + anchors.headRadius * 0.16, anchors.headRadius * 0.5, anchors.headRadius * 0.38, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#111827";
ctx.beginPath();
ctx.arc(anchors.headX, anchors.headY + anchors.headRadius * 0.04, anchors.headRadius * 0.09, 0, Math.PI * 2);
ctx.fill();
drawRoundedBody(ctx, anchors, "rgba(180,83,9,0.93)");
drawLimbs(ctx, anchors, "rgba(180,83,9,0.88)");
}
export function drawLiveCameraOverlay(
canvas: HTMLCanvasElement | null,
landmarks: PosePoint[] | undefined,
avatarState?: AvatarRenderState,
) {
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!landmarks) return;
if (avatarState?.enabled) {
const anchors = getAvatarAnchors(landmarks, canvas.width, canvas.height);
if (anchors) {
ctx.save();
ctx.globalAlpha = 0.95;
if (avatarState.avatarKey === "monkey") {
drawMonkeyAvatar(ctx, anchors);
} else if (avatarState.avatarKey === "pig") {
drawPigAvatar(ctx, anchors);
} else if (avatarState.avatarKey === "dog") {
drawDogAvatar(ctx, anchors);
} else {
drawGorillaAvatar(ctx, anchors);
}
ctx.restore();
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.16)";
ctx.lineWidth = 2;
ctx.setLineDash([8, 10]);
ctx.beginPath();
ctx.moveTo(anchors.bodyX, anchors.shoulderY - anchors.headRadius * 1.25);
ctx.lineTo(anchors.bodyX, anchors.footY);
ctx.stroke();
ctx.restore();
return;
}
}
const poseConnections: Array<[number, number]> = [
[11, 12], [11, 13], [13, 15], [12, 14], [14, 16],
[11, 23], [12, 24], [23, 24], [23, 25], [24, 26],
[25, 27], [26, 28], [15, 17], [16, 18], [15, 19],
[16, 20], [17, 19], [18, 20],
];
ctx.strokeStyle = "rgba(25, 211, 155, 0.9)";
ctx.lineWidth = 3;
poseConnections.forEach(([from, to]) => {
const start = landmarks[from];
const end = landmarks[to];
if (!start || !end || (start.visibility ?? 1) < 0.25 || (end.visibility ?? 1) < 0.25) return;
ctx.beginPath();
ctx.moveTo(start.x * canvas.width, start.y * canvas.height);
ctx.lineTo(end.x * canvas.width, end.y * canvas.height);
ctx.stroke();
});
landmarks.forEach((point, index) => {
if ((point.visibility ?? 1) < 0.25) return;
ctx.fillStyle = index >= 11 && index <= 16 ? "rgba(253, 224, 71, 0.95)" : "rgba(255,255,255,0.88)";
ctx.beginPath();
ctx.arc(point.x * canvas.width, point.y * canvas.height, index >= 11 && index <= 16 ? 5 : 4, 0, Math.PI * 2);
ctx.fill();
});
}