Fix live camera gorilla avatar preset
这个提交包含在:
115
client/src/lib/liveCamera.test.ts
普通文件
115
client/src/lib/liveCamera.test.ts
普通文件
@@ -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
普通文件
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();
|
||||
});
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户