From fe5e539a4786755a35eefd50ed0fb66ce46a6621 Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Sun, 15 Mar 2026 22:02:18 +0800 Subject: [PATCH] Add 10 free animal live camera avatars --- client/public/avatars/twemoji/ATTRIBUTION.txt | 20 ++ client/public/avatars/twemoji/cat.svg | 1 + client/public/avatars/twemoji/dog.svg | 1 + client/public/avatars/twemoji/fox.svg | 1 + client/public/avatars/twemoji/gorilla.svg | 1 + client/public/avatars/twemoji/lion.svg | 1 + client/public/avatars/twemoji/monkey.svg | 1 + client/public/avatars/twemoji/panda.svg | 1 + client/public/avatars/twemoji/pig.svg | 1 + client/public/avatars/twemoji/rabbit.svg | 1 + client/public/avatars/twemoji/tiger.svg | 1 + client/src/lib/liveCamera.test.ts | 2 + client/src/lib/liveCamera.ts | 245 +++++++++++------- client/src/pages/LiveCamera.tsx | 8 +- docs/CHANGELOG.md | 5 +- 15 files changed, 186 insertions(+), 104 deletions(-) create mode 100644 client/public/avatars/twemoji/ATTRIBUTION.txt create mode 100644 client/public/avatars/twemoji/cat.svg create mode 100644 client/public/avatars/twemoji/dog.svg create mode 100644 client/public/avatars/twemoji/fox.svg create mode 100644 client/public/avatars/twemoji/gorilla.svg create mode 100644 client/public/avatars/twemoji/lion.svg create mode 100644 client/public/avatars/twemoji/monkey.svg create mode 100644 client/public/avatars/twemoji/panda.svg create mode 100644 client/public/avatars/twemoji/pig.svg create mode 100644 client/public/avatars/twemoji/rabbit.svg create mode 100644 client/public/avatars/twemoji/tiger.svg diff --git a/client/public/avatars/twemoji/ATTRIBUTION.txt b/client/public/avatars/twemoji/ATTRIBUTION.txt new file mode 100644 index 0000000..9fbacff --- /dev/null +++ b/client/public/avatars/twemoji/ATTRIBUTION.txt @@ -0,0 +1,20 @@ +Animal avatar SVG assets in this folder are sourced from Twemoji. + +Source: +https://github.com/jdecked/twemoji + +Selected assets: +- gorilla.svg +- monkey.svg +- dog.svg +- pig.svg +- cat.svg +- fox.svg +- panda.svg +- lion.svg +- tiger.svg +- rabbit.svg + +License: +CC-BY 4.0 +https://creativecommons.org/licenses/by/4.0/ diff --git a/client/public/avatars/twemoji/cat.svg b/client/public/avatars/twemoji/cat.svg new file mode 100644 index 0000000..cc75dcc --- /dev/null +++ b/client/public/avatars/twemoji/cat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/avatars/twemoji/dog.svg b/client/public/avatars/twemoji/dog.svg new file mode 100644 index 0000000..8b2e685 --- /dev/null +++ b/client/public/avatars/twemoji/dog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/avatars/twemoji/fox.svg b/client/public/avatars/twemoji/fox.svg new file mode 100644 index 0000000..2cb2f98 --- /dev/null +++ b/client/public/avatars/twemoji/fox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/avatars/twemoji/gorilla.svg b/client/public/avatars/twemoji/gorilla.svg new file mode 100644 index 0000000..aa59923 --- /dev/null +++ b/client/public/avatars/twemoji/gorilla.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/avatars/twemoji/lion.svg b/client/public/avatars/twemoji/lion.svg new file mode 100644 index 0000000..674ff24 --- /dev/null +++ b/client/public/avatars/twemoji/lion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/avatars/twemoji/monkey.svg b/client/public/avatars/twemoji/monkey.svg new file mode 100644 index 0000000..ee6c57c --- /dev/null +++ b/client/public/avatars/twemoji/monkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/avatars/twemoji/panda.svg b/client/public/avatars/twemoji/panda.svg new file mode 100644 index 0000000..8607893 --- /dev/null +++ b/client/public/avatars/twemoji/panda.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/avatars/twemoji/pig.svg b/client/public/avatars/twemoji/pig.svg new file mode 100644 index 0000000..49175ea --- /dev/null +++ b/client/public/avatars/twemoji/pig.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/avatars/twemoji/rabbit.svg b/client/public/avatars/twemoji/rabbit.svg new file mode 100644 index 0000000..2f70f96 --- /dev/null +++ b/client/public/avatars/twemoji/rabbit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/avatars/twemoji/tiger.svg b/client/public/avatars/twemoji/tiger.svg new file mode 100644 index 0000000..5ecd980 --- /dev/null +++ b/client/public/avatars/twemoji/tiger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/lib/liveCamera.test.ts b/client/src/lib/liveCamera.test.ts index 67bbf0d..9600146 100644 --- a/client/src/lib/liveCamera.test.ts +++ b/client/src/lib/liveCamera.test.ts @@ -90,6 +90,8 @@ 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("狐狸风格", "gorilla")).toBe("fox"); + expect(resolveAvatarKeyFromPrompt("兔子教练", "gorilla")).toBe("rabbit"); expect(resolveAvatarKeyFromPrompt("", "pig")).toBe("pig"); }); diff --git a/client/src/lib/liveCamera.ts b/client/src/lib/liveCamera.ts index 2e9902f..d75a4b2 100644 --- a/client/src/lib/liveCamera.ts +++ b/client/src/lib/liveCamera.ts @@ -6,7 +6,17 @@ export type PosePoint = { visibility?: number; }; -export type AvatarKey = "gorilla" | "monkey" | "pig" | "dog"; +export type AvatarKey = + | "gorilla" + | "monkey" + | "dog" + | "pig" + | "cat" + | "fox" + | "panda" + | "lion" + | "tiger" + | "rabbit"; export type AvatarRenderState = { enabled: boolean; @@ -69,6 +79,13 @@ type AvatarAnchors = { rightHandY: number; }; +type AvatarVisualSpec = { + src: string; + bodyFill: string; + limbStroke: string; + glow: string; +}; + const ACTIONS: LiveActionType[] = ["forehand", "backhand", "serve", "volley", "overhead", "slice", "lob", "unknown"]; export const ACTION_WINDOW_FRAMES = 24; @@ -82,10 +99,81 @@ 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", "犬", "狗", "小狗"] }, + { key: "pig", label: "猪", keywords: ["pig", "猪", "小猪"] }, + { key: "cat", label: "猫", keywords: ["cat", "kitty", "猫", "小猫"] }, + { key: "fox", label: "狐狸", keywords: ["fox", "狐狸"] }, + { key: "panda", label: "熊猫", keywords: ["panda", "熊猫"] }, + { key: "lion", label: "狮子", keywords: ["lion", "狮子"] }, + { key: "tiger", label: "老虎", keywords: ["tiger", "虎", "老虎"] }, + { key: "rabbit", label: "兔子", keywords: ["rabbit", "bunny", "兔", "兔子"] }, ]; +const AVATAR_VISUALS: Record = { + gorilla: { + src: "/avatars/twemoji/gorilla.svg", + bodyFill: "rgba(39,39,42,0.95)", + limbStroke: "rgba(63,63,70,0.92)", + glow: "rgba(161,161,170,0.32)", + }, + monkey: { + src: "/avatars/twemoji/monkey.svg", + bodyFill: "rgba(120,53,15,0.95)", + limbStroke: "rgba(146,64,14,0.9)", + glow: "rgba(180,83,9,0.3)", + }, + dog: { + src: "/avatars/twemoji/dog.svg", + bodyFill: "rgba(180,83,9,0.93)", + limbStroke: "rgba(180,83,9,0.88)", + glow: "rgba(217,119,6,0.26)", + }, + pig: { + src: "/avatars/twemoji/pig.svg", + bodyFill: "rgba(244,114,182,0.92)", + limbStroke: "rgba(244,114,182,0.86)", + glow: "rgba(244,114,182,0.28)", + }, + cat: { + src: "/avatars/twemoji/cat.svg", + bodyFill: "rgba(245,158,11,0.92)", + limbStroke: "rgba(217,119,6,0.88)", + glow: "rgba(251,191,36,0.28)", + }, + fox: { + src: "/avatars/twemoji/fox.svg", + bodyFill: "rgba(234,88,12,0.93)", + limbStroke: "rgba(194,65,12,0.9)", + glow: "rgba(251,146,60,0.3)", + }, + panda: { + src: "/avatars/twemoji/panda.svg", + bodyFill: "rgba(82,82,91,0.92)", + limbStroke: "rgba(39,39,42,0.9)", + glow: "rgba(228,228,231,0.28)", + }, + lion: { + src: "/avatars/twemoji/lion.svg", + bodyFill: "rgba(202,138,4,0.92)", + limbStroke: "rgba(161,98,7,0.9)", + glow: "rgba(250,204,21,0.28)", + }, + tiger: { + src: "/avatars/twemoji/tiger.svg", + bodyFill: "rgba(249,115,22,0.94)", + limbStroke: "rgba(194,65,12,0.9)", + glow: "rgba(251,146,60,0.3)", + }, + rabbit: { + src: "/avatars/twemoji/rabbit.svg", + bodyFill: "rgba(236,72,153,0.9)", + limbStroke: "rgba(219,39,119,0.86)", + glow: "rgba(244,114,182,0.28)", + }, +}; + +const avatarImageCache = new Map(); + function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } @@ -345,105 +433,73 @@ function drawLimbs(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, stroke 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(); +function getAvatarImage(key: AvatarKey) { + if (typeof Image === "undefined") { + return null; + } - 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(); + const cached = avatarImageCache.get(key); + if (cached) { + return cached.complete && cached.naturalWidth > 0 ? cached : null; + } - 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)"); + const image = new Image(); + image.decoding = "async"; + image.src = AVATAR_VISUALS[key].src; + avatarImageCache.set(key, image); + return null; } -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(); +function drawAvatarBadge( + ctx: CanvasRenderingContext2D, + anchors: AvatarAnchors, + avatarKey: AvatarKey, + sprite: HTMLImageElement | null, +) { + const visual = AVATAR_VISUALS[avatarKey]; + const headSize = anchors.headRadius * 2.5; + const torsoBadge = Math.max(anchors.headRadius * 0.95, 40); - 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, visual.bodyFill); + drawLimbs(ctx, anchors, visual.limbStroke); - drawRoundedBody(ctx, anchors, "rgba(120,53,15,0.95)"); - drawLimbs(ctx, anchors, "rgba(146,64,14,0.9)"); -} + ctx.save(); + ctx.fillStyle = visual.glow; + ctx.beginPath(); + ctx.arc(anchors.headX, anchors.headY, anchors.headRadius * 1.16, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); -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(); + if (sprite) { + ctx.drawImage( + sprite, + anchors.headX - headSize / 2, + anchors.headY - headSize / 2, + headSize, + headSize, + ); + ctx.save(); + ctx.globalAlpha = 0.94; + ctx.drawImage( + sprite, + anchors.bodyX - torsoBadge / 2, + anchors.bodyY - torsoBadge / 2, + torsoBadge, + torsoBadge, + ); + ctx.restore(); + return; + } - ctx.fillStyle = "#fbcfe8"; + ctx.fillStyle = "rgba(255,255,255,0.92)"; 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.arc(anchors.headX, anchors.headY, anchors.headRadius * 0.88, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = "#be185d"; + ctx.fillStyle = "rgba(17,24,39,0.82)"; 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.arc(anchors.headX - anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 0, Math.PI * 2); + ctx.arc(anchors.headX + anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 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( @@ -459,17 +515,10 @@ export function drawLiveCameraOverlay( if (avatarState?.enabled) { const anchors = getAvatarAnchors(landmarks, canvas.width, canvas.height); if (anchors) { + const sprite = getAvatarImage(avatarState.avatarKey); 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); - } + drawAvatarBadge(ctx, anchors, avatarState.avatarKey, sprite); ctx.restore(); ctx.save(); diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index cff5921..e5877d9 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -1320,7 +1320,7 @@ export default function LiveCamera() {

实时分析中枢

- 摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;开启虚拟形象后,画面中的人体会被猩猩或其他卡通形象覆盖显示。 + 摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;开启虚拟形象后,画面中的人体会被猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎或兔子形象覆盖显示。

@@ -1455,7 +1455,7 @@ export default function LiveCamera() {
虚拟形象替换
- 开启后实时画面会用卡通形象覆盖主体,仅影响前端叠加显示,不改变动作识别与原视频归档。 + 开启后实时画面会用 10 个免费动物虚拟形象之一覆盖主体,仅影响前端叠加显示,不改变动作识别与原视频归档。
当前映射:{resolvedAvatarLabel} - {avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入别名自动映射到内置形象"} + {avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入猩猩、狐狸、熊猫、兔子等别名自动映射"}
@@ -1488,7 +1488,7 @@ export default function LiveCamera() { setAvatarPrompt(event.target.value)} - placeholder="例如 猴子 / dog mascot" + placeholder="例如 狐狸 / panda coach / dog mascot" className="h-12 rounded-2xl border-border/60" />
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2f5baeb..ad99d56 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,12 +4,13 @@ ### 功能更新 -- `/live-camera` 新增“虚拟形象替换”开关,可将主体实时替换为猩猩、猴子、猪、狗四种卡通形象 -- 增加形象别名输入,当前可按输入内容自动映射到内置形象,后续可继续扩展新的虚拟形象 +- `/live-camera` 新增 10 个免费动物虚拟形象,可将主体实时替换为猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎、兔子 +- 增加形象别名输入,当前可按输入内容自动映射到内置形象 - 实时分析动作稳定器从短窗口切换为 24 帧时间窗口,降低 1-2 秒内频繁跳动作的问题 - 动作切换新增确认阶段与延迟入库逻辑,连续动作区间改为只按稳定动作聚合 - 画面内新增稳定动作、原始候选、窗口占比、切换确认状态等实时状态提示 - 实时分析会话保存新增稳定窗口、动作切换次数、原始波动率、虚拟形象状态等指标 +- 动物头像素材切换为本地集成的免费 Twemoji SVG,避免外链依赖 ### 测试