From a8d00597f49ea2cf88b55e8eb928daa945933486 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Sat, 31 Oct 2026 11:56:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(training):=20=E4=BF=AE=E5=A4=8D=20AI=20?= =?UTF-8?q?=E8=B7=9F=E7=BB=83=E7=9C=9F=E6=9C=BA=E4=B8=8D=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E5=92=8C=E8=AF=AF=E8=AE=A1=E6=95=B0=EF=BC=8C=E6=94=BE=E5=AE=BD?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E8=A6=81=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机日志定位的问题: - 画面外的关键点坐标是 NaN 或字符串,平滑后永久污染膝、踝坐标,深蹲指标恒为 0、站位却被误判通过。 入口处把非数字坐标当作看不见,平滑时上一帧无效就用新坐标重新开始 - 识别引擎单帧跳动越过计数线也会计一次:连续 2 帧达到计数线才算开始;深蹲连续 2 帧回到站立才算完成 - 离太近时腿部点置信度只有 0.1~0.2、位置靠猜:计数的可见阈值保持 0.3,不用膝盖替补脚踝 按真机跟练的实际幅度放宽:深蹲 0.18→0.13、高抬腿 0.22→0.16、开合跳 120°→95°、侧平举 68°→58°, 左右只看清一侧也能算;开合跳 140° 满分,没迈步只提示不压分。 --- .../training/pose/__tests__/counters.test.ts | 145 ++++++++++++++++++ .../training/pose/__tests__/keypoints.test.ts | 60 ++++++++ TUICallKit-Vue3/training/pose/counters.ts | 128 +++++++++++----- TUICallKit-Vue3/training/pose/keypoints.ts | 21 ++- 4 files changed, 310 insertions(+), 44 deletions(-) create mode 100644 TUICallKit-Vue3/training/pose/__tests__/keypoints.test.ts diff --git a/TUICallKit-Vue3/training/pose/__tests__/counters.test.ts b/TUICallKit-Vue3/training/pose/__tests__/counters.test.ts index 86aad2280..32868fdaa 100644 --- a/TUICallKit-Vue3/training/pose/__tests__/counters.test.ts +++ b/TUICallKit-Vue3/training/pose/__tests__/counters.test.ts @@ -131,3 +131,148 @@ test('幅度条目标线:到达目标幅度才计数', () => { assert.ok(c.goal > 0.2 && c.goal < 0.9, `${m} goal=${c.goal}`) } }) + +/** 把示范动作的幅度按比例缩小(与站立姿势线性插值),模拟做得没那么到位的用户 */ +function shallow(move: MoveId, k: number, seconds: number, fps = 12): Pose[] { + const stand = MOTIONS[move].poseAt(0) + return sampleMotion(move, seconds, fps).map((f) => ({ + ...f, + points: f.points.map((p, i) => ({ x: stand[i].x + (p.x - stand[i].x) * k, y: stand[i].y + (p.y - stand[i].y) * k })), + })) +} + + +test('squat: 左右只看清一侧的髋和踝也能计数', () => { + const cycles = 4 + const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3).map((f) => ({ + ...f, + scores: f.scores.map((s, i) => ([12, 16].includes(i) ? 0.05 : s)), + })) + assert.equal(run('squat', frames).count, cycles) +}) + + +test('动作要求放宽:幅度没到旧标准、但到了新标准的动作也计数', () => { + // 各动作选一个幅度:峰值落在旧进入阈值和新进入阈值之间(放宽前不计、放宽后计数) + const cases: Array<[MoveId, number, number]> = [ + ['squat', 0.45, 0.18], + ['march', 0.3, 0.22], + ['jack', 0.52, 120], + ['raise', 0.55, 68], + ] + for (const [m, k, oldEnter] of cases) { + const cycles = 4 + const c = createCounter(m) + let peak = 0 + for (const f of shallow(m, k, MOTIONS[m].cycleSeconds * cycles + 0.3)) { + c.update(f) + if (c.metric !== null) peak = Math.max(peak, c.metric) + } + assert.ok(peak < oldEnter, `${m} peak ${peak} 应低于旧阈值 ${oldEnter}`) + assert.equal(c.count, cycles * MOTIONS[m].repsPerCycle, `${m} count ${c.count}`) + } +}) + +test('squat: 蹲到底时脚踝偶尔一帧没识别到,不会多计(真机复现)', () => { + const cycles = 4 + const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3) + const c = createCounter('squat') + frames.forEach((f, i) => { + // 蹲下过程中(幅度大于 0.2)每隔几帧丢一次脚踝 + const deep = (c.metric ?? 0) > 0.2 + const drop = deep && i % 4 === 0 + c.update({ ...f, scores: f.scores.map((s, k) => (drop && (k === 15 || k === 16) ? 0.05 : s)) }) + }) + assert.equal(c.count, cycles) +}) + +test('单帧识别抖动回到站立位置不计数:需要连续两帧', () => { + const cycles = 3 + const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3) + const c = createCounter('squat') + const stand = frames[0] + frames.forEach((f, i) => { + c.update(f) + // 蹲到底时插入一帧“站立”噪声 + if ((c.metric ?? 0) > 0.3 && i % 3 === 0) c.update({ ...stand, t: f.t + 1 }) + }) + assert.equal(c.count, cycles) +}) + +test('离得太近、腿部关键点置信度很低时不计数(真机复现:脚踝位置乱飘、人没有下蹲却被计数)', () => { + const stand = sampleMotion('squat', 0.1)[0] + const c = createCounter('squat') + for (let i = 0; i < 60; i++) { + const ankleSeen = i % 3 !== 0 + const drift = 0.15 * Math.abs(Math.sin(i / 4)) // 脚踝的识别位置在乱飘 + c.update({ + ...stand, + t: i * 100, + points: stand.points.map((p, k) => (k === 15 || k === 16 ? { x: p.x, y: p.y - drift } : p)), + scores: stand.scores.map((_, k) => ([11, 12, 13, 14].includes(k) ? 0.15 : k === 15 || k === 16 ? (ankleSeen ? 0.22 : 0.05) : 0.6)), + }) + } + assert.equal(c.count, 0) +}) + +test('jack: 真机跟练的抬手幅度(手高过肩约 100°)计数,只举到与肩平不计', () => { + const c = createCounter('jack') + const sweep = (peak: number, t0: number) => { + // 手臂外展角 20° → peak → 20°,用示范动作在对应角度附近的姿态合成 + const frames = sampleMotion('jack', MOTIONS.jack.cycleSeconds) + let best = frames[0] + let bestDiff = Infinity + for (const f of frames) { + c.update({ ...f, t: t0 }) + const d = Math.abs((c.metric ?? 0) - peak) + if (d < bestDiff) [best, bestDiff] = [f, d] + } + return best + } + // 先找到 ~100° 和 ~88° 的姿态,再用它们做重复动作 + const c100 = sweep(100, 0) + const c88 = sweep(88, 0) + const rest = sampleMotion('jack', 0.1)[0] + const counter = createCounter('jack') + let t = 0 + for (let i = 0; i < 3; i++) for (const f of [rest, rest, c100, c100, rest, rest]) counter.update({ ...f, t: (t += 150) }) + assert.equal(counter.count, 3, '约 100° 应计数') + const before = counter.count + for (let i = 0; i < 3; i++) for (const f of [rest, rest, c88, c88, rest, rest]) counter.update({ ...f, t: (t += 150) }) + assert.equal(counter.count, before, '约 88° 不应计数') +}) + +test('单帧识别跳动越过计数线不计数(真机复现:高抬腿一帧跳到 0.46 又落回)', () => { + for (const m of ['squat', 'march', 'jack', 'raise'] as MoveId[]) { + const frames = sampleMotion(m, MOTIONS[m].cycleSeconds * 2) + const rest = frames[0] + // 找一帧“到位”的姿态当作跳动 + const probe = createCounter(m) + let spike = rest + let best = -Infinity + for (const f of frames) { + probe.update(f) + if ((probe.metric ?? -Infinity) > best) [best, spike] = [probe.metric ?? -Infinity, f] + } + const c = createCounter(m) + let t = 0 + for (let i = 0; i < 5; i++) for (const f of [rest, rest, rest, spike, rest, rest, rest]) c.update({ ...f, t: (t += 100) }) + assert.equal(c.count, 0, `${m} 单帧跳动被计了 ${c.count} 次`) + } +}) + +test('jack: 不迈步只提示、不压分,提示 20 秒内最多一次', () => { + const cycles = 6 + const stand = MOTIONS.jack.poseAt(0) + // 脚始终保持站立时的位置(不迈步),手臂照常 + const frames = sampleMotion('jack', MOTIONS.jack.cycleSeconds * cycles + 0.3).map((f) => ({ + ...f, + points: f.points.map((p, i) => ([15, 16, 19, 20, 21, 22].includes(i) ? stand[i] : p)), + })) + const r = run('jack', frames) + assert.equal(r.count, cycles * MOTIONS.jack.repsPerCycle) + for (const rep of r.reps) if (rep.type === 'rep') assert.ok(rep.score >= 90, `score ${rep.score}`) + const stepTips = r.tips.filter((t) => t!.includes('迈一步')).length + const seconds = MOTIONS.jack.cycleSeconds * cycles + assert.ok(stepTips >= 1 && stepTips <= Math.ceil(seconds / 20), `step tips ${stepTips}`) +}) diff --git a/TUICallKit-Vue3/training/pose/__tests__/keypoints.test.ts b/TUICallKit-Vue3/training/pose/__tests__/keypoints.test.ts new file mode 100644 index 000000000..5db2397be --- /dev/null +++ b/TUICallKit-Vue3/training/pose/__tests__/keypoints.test.ts @@ -0,0 +1,60 @@ +/** + * 关键点转换与平滑:真机上画面外的点坐标是 NaN,不能让它污染后续帧。 + * 运行:node --test training/pose/__tests__/ + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { anchorToPose, checkFraming, smoothPose, KP, type Pose } from '../keypoints.ts' +import { createCounter } from '../counters.ts' +import { MOTIONS } from '../motions.ts' +import { sampleMotion } from './sampleMotion.ts' + +const LEG = [KP.L_KNEE, KP.R_KNEE, KP.L_ANKLE, KP.R_ANKLE] + +/** 示范姿态转成 VisionKit 的 anchor,指定的点坐标为 NaN(画面外) */ +function anchorOf(pose: Pose, nanIdx: number[] = []) { + return { + points: pose.points.map((p, i) => (nanIdx.includes(i) ? { x: NaN, y: NaN } : { x: p.x, y: p.y })), + confidence: pose.scores.map((s, i) => (nanIdx.includes(i) ? 0.9 : s)), + score: 0.9, + } +} + +test('anchorToPose:坐标为 NaN 的点视为看不见', () => { + const [f] = sampleMotion('squat', 0.1) + const p = anchorToPose(anchorOf(f, LEG), 480, 853, 0)! + for (const i of LEG) { + assert.equal(p.scores[i], 0, `score ${i}`) + assert.ok(Number.isFinite(p.points[i].x) && Number.isFinite(p.points[i].y), `point ${i}`) + } +}) + +test('smoothPose:上一帧是 NaN 时直接采用新坐标,不会一直是 NaN', () => { + const [f] = sampleMotion('squat', 0.1) + const poisoned: Pose = { ...f, points: f.points.map((p, i) => (LEG.includes(i) ? { x: NaN, y: NaN } : p)) } + const next = smoothPose(poisoned, f) + for (const i of LEG) assert.ok(Number.isFinite(next.points[i].y), `point ${i}`) +}) + +test('站位:脚踝坐标是 NaN 时不能判定为站位良好', () => { + const [f] = sampleMotion('squat', 0.1) + const p: Pose = { ...f, points: f.points.map((q, i) => (i === KP.L_ANKLE || i === KP.R_ANKLE ? { x: NaN, y: NaN } : q)) } + assert.notEqual(checkFraming(p), 'none') +}) + +test('真机复现:开始时腿在画面外(NaN),退回画面后深蹲照常计数', () => { + const cycles = 4 + const frames = sampleMotion('squat', MOTIONS.squat.cycleSeconds * cycles + 0.3) + const counter = createCounter('squat') + let smoothed: Pose | null = null + // 前 1 秒:离得太近,膝和踝在画面外 + for (let i = 0; i < 12; i++) { + const raw = anchorToPose(anchorOf(frames[0], LEG), 480, 853, i * 83)! + smoothed = smoothPose(smoothed, raw) + } + for (const f of frames) { + smoothed = smoothPose(smoothed, anchorToPose(anchorOf(f), 480, 853, f.t)!) + counter.update(smoothed) + } + assert.equal(counter.count, cycles) +}) diff --git a/TUICallKit-Vue3/training/pose/counters.ts b/TUICallKit-Vue3/training/pose/counters.ts index ef95af188..87409f3cb 100644 --- a/TUICallKit-Vue3/training/pose/counters.ts +++ b/TUICallKit-Vue3/training/pose/counters.ts @@ -8,7 +8,7 @@ * * 纯逻辑,不依赖 uni / wx,可直接用 Node 单测。 */ -import { KP, angleAt, isVisible, mid, pt, shoulderWidth, torsoLength, type Pose } from './keypoints.ts' +import { KP, MIN_POINT_SCORE, angleAt, isVisible, mid, pt, shoulderWidth, type Point, type Pose } from './keypoints.ts' import type { Amount, MoveId, Side } from './motions.ts' export type Grade = 'perfect' | 'good' | 'ok' @@ -24,6 +24,8 @@ export interface RepCounter { readonly live: Amount /** 计一次所需的动作幅度(进入阈值换算成 0~1),界面幅度条上的目标线 */ readonly goal: number + /** 最近一帧的原始指标;关键点看不清、这一帧没算出来时为 null(诊断用) */ + readonly metric: number | null update(pose: Pose): CoachEvent[] reset(): void } @@ -51,6 +53,8 @@ type Rule = { checkForm?: (pose: Pose, ctx: Ctx, flags: Set) => void /** 最短单次时长(ms),更快则提示放慢 */ minRepMs?: number + /** 连续几帧回到起始位才算完成一次(默认 1):在起始位会停留的动作设 2,单帧识别抖动不会多计 */ + exitFrames?: number /** 指标 → 动作幅度(0 = 起始姿势,1 = 标准到位) */ amount: (v: number) => number /** 左右交替的动作:当前是哪一侧 */ @@ -93,6 +97,14 @@ export function tipTextsOf(move: MoveId): string[] { } const TIP_COOLDOWN_MS = 3500 +/** 只是建议、不影响计数和评分的提示,冷却更久,免得每次都念 */ +const ADVICE_TIPS: ReadonlySet = new Set([TIPS.jack.step]) +const ADVICE_COOLDOWN_MS = 20000 +/** + * 连续几帧达到计数线才算动作开始:识别引擎偶尔有单帧跳动(真机:高抬腿一帧从 0.05 跳到 0.46 又落回), + * 真实动作在计数线以上至少持续 0.3 秒,两帧确认不影响正常计数 + */ +const ENTER_FRAMES = 2 const gradeOf = (score: number): Grade => (score >= 90 ? 'perfect' : score >= 75 ? 'good' : 'ok') @@ -101,8 +113,30 @@ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v /** 线性映射到分数区间 */ const scale = (v: number, v0: number, v1: number, s0: number, s1: number) => s0 + (s1 - s0) * clamp((v - v0) / (v1 - v0), 0, 1) +/** + * 计数用的关键点可见阈值,与站位一致。 + * 真机实测:离得太近、腿被画面裁掉时,腿部点置信度只有 0.1~0.2,位置是模型猜的(膝盖跑到髋上方、脚踝乱飘), + * 放宽到 0.2 会把这些点拿来计数、没下蹲也计数。看不清时宁可不计,由页面提示用户后退。 + */ +export const TRACK_MIN_SCORE = MIN_POINT_SCORE + function visibleAll(pose: Pose, idx: number[]) { - return idx.every((i) => isVisible(pose, i)) + return idx.every((i) => isVisible(pose, i, TRACK_MIN_SCORE)) +} + +/** 一组点里看得清的那些的平均位置(左右成对的点只看清一个也能用);一个都看不清返回 null */ +function avgVisible(pose: Pose, idx: number[]): Point | null { + const seen = idx.filter((i) => isVisible(pose, i, TRACK_MIN_SCORE)).map((i) => pt(pose, i)) + if (!seen.length) return null + return { x: seen.reduce((s, p) => s + p.x, 0) / seen.length, y: seen.reduce((s, p) => s + p.y, 0) / seen.length } +} + +/** 躯干长度:看得清的肩、髋的中点之间的距离 */ +function visibleTorso(pose: Pose): number | null { + const sh = avgVisible(pose, [KP.L_SHOULDER, KP.R_SHOULDER]) + const hip = avgVisible(pose, [KP.L_HIP, KP.R_HIP]) + if (!sh || !hip) return null + return Math.hypot(sh.x - hip.x, sh.y - hip.y) } /** 肩外展角:躯干侧线(髋→肩)与手臂(肩→腕)的夹角 */ @@ -123,29 +157,30 @@ function avgAbduction(pose: Pose): number | null { // ─────────────────────────── 各动作规则 ─────────────────────────── -/** 深蹲:髋部下降比例 = 1 - 当前(踝→髋高度) / 站立时(踝→髋高度) */ +/** + * 深蹲:髋部下降比例 = 1 - 当前(踝→髋高度) / 站立时(踝→髋高度)。左右髋、左右踝各看清一个就够。 + * 不用髋→膝高度做替补:真机上脚踝看不清时膝盖往往也是猜的,两种估算逐帧交替会算出 0 和 0.4 来回跳,凭空计数。 + */ function squatRule(): Rule { - const legSpan = (pose: Pose) => { - const hip = mid(pt(pose, KP.L_HIP), pt(pose, KP.R_HIP)) - const ankle = mid(pt(pose, KP.L_ANKLE), pt(pose, KP.R_ANKLE)) - return ankle.y - hip.y - } return { metric(pose, ctx) { - if (!visibleAll(pose, [KP.L_HIP, KP.R_HIP, KP.L_ANKLE, KP.R_ANKLE])) return null - const span = legSpan(pose) + const hip = avgVisible(pose, [KP.L_HIP, KP.R_HIP]) + const ankle = avgVisible(pose, [KP.L_ANKLE, KP.R_ANKLE]) + if (!hip || !ankle) return null + const span = ankle.y - hip.y const b = ctx.baseline // 站立基线:取近期最大值,并缓慢衰减以适应前后走动 b.legSpan = Math.max(span, (b.legSpan ?? span) * 0.998) - return clamp(1 - span / b.legSpan, 0, 1) + return b.legSpan > 0 ? clamp(1 - span / b.legSpan, 0, 1) : null }, - enter: 0.18, - exit: 0.08, - attempt: 0.1, + enter: 0.13, + exit: 0.06, + attempt: 0.08, attemptTip: TIPS.squat.shallowAttempt, amount: (v) => clamp(v / 0.34, 0, 1), attemptJoints: [KP.L_HIP, KP.R_HIP], minRepMs: 1200, + exitFrames: 2, checkForm(pose, _ctx, flags) { if (!visibleAll(pose, [KP.L_KNEE, KP.R_KNEE, KP.L_ANKLE, KP.R_ANKLE])) return const kneeGap = Math.abs(pt(pose, KP.L_KNEE).x - pt(pose, KP.R_KNEE).x) @@ -154,7 +189,7 @@ function squatRule(): Rule { if (hipDrop && ankleGap > 0 && kneeGap / ankleGap < 0.8) flags.add('valgus') }, judge(peak, trace) { - let score = scale(peak, 0.18, 0.32, 70, 100) + let score = scale(peak, 0.13, 0.3, 70, 100) if (trace.flags.has('valgus')) { score = Math.min(score, 72) return { score, tip: TIPS.squat.valgus, joints: [KP.L_KNEE, KP.R_KNEE] } @@ -169,30 +204,30 @@ function squatRule(): Rule { function legSpanDropFlag(pose: Pose): boolean { const hip = mid(pt(pose, KP.L_HIP), pt(pose, KP.R_HIP)) const ankle = mid(pt(pose, KP.L_ANKLE), pt(pose, KP.R_ANKLE)) - const torso = torsoLength(pose) + const torso = visibleTorso(pose) ?? 0 return torso > 0 && (ankle.y - hip.y) / torso < 1.25 } -/** 高抬腿:抬起侧膝盖比支撑侧膝盖高出多少(按躯干长度归一) */ +/** 高抬腿:抬起侧膝盖比支撑侧膝盖高出多少(按躯干长度归一;肩、髋各看清一侧即可) */ function marchRule(): Rule { return { metric(pose) { - if (!visibleAll(pose, [KP.L_KNEE, KP.R_KNEE, KP.L_SHOULDER, KP.R_SHOULDER, KP.L_HIP, KP.R_HIP])) return null - const torso = torsoLength(pose) - if (torso <= 0) return null + if (!visibleAll(pose, [KP.L_KNEE, KP.R_KNEE])) return null + const torso = visibleTorso(pose) + if (!torso) return null const diff = Math.abs(pt(pose, KP.L_KNEE).y - pt(pose, KP.R_KNEE).y) return diff / torso }, - enter: 0.22, - exit: 0.08, - attempt: 0.09, + enter: 0.16, + exit: 0.06, + attempt: 0.08, attemptTip: TIPS.march.lowAttempt, amount: (v) => clamp(v / 0.55, 0, 1), side: (pose) => (pt(pose, KP.L_KNEE).y < pt(pose, KP.R_KNEE).y ? 'L' : 'R'), attemptJoints: [KP.L_KNEE, KP.R_KNEE], minRepMs: 250, judge(peak) { - const score = scale(peak, 0.22, 0.45, 72, 100) + const score = scale(peak, 0.16, 0.45, 72, 100) if (peak < 0.3) return { score, tip: TIPS.march.low, joints: [KP.L_KNEE, KP.R_KNEE] } return { score } }, @@ -203,7 +238,8 @@ function marchRule(): Rule { function jackRule(): Rule { return { metric: (pose) => avgAbduction(pose), - enter: 120, + // 手臂高过肩就算(只举到与肩平约 90° 仍不计)。真机实测跟练用户的峰值多在 99°~110°,远低于示范的 178° + enter: 95, exit: 50, attempt: 65, attemptTip: TIPS.jack.armsAttempt, @@ -225,11 +261,10 @@ function jackRule(): Rule { if (spread >= 1.05) flags.add('stepped') }, judge(peak, trace) { - let score = scale(peak, 120, 155, 72, 100) - if (!trace.flags.has('stepped')) { - score = Math.min(score, 78) - return { score, tip: TIPS.jack.step, joints: [KP.L_ANKLE, KP.R_ANKLE] } - } + // 满分线按真机跟练的实际幅度定(示范 178°,用户多在 100°~130°):举到 140° 就是满分 + const score = scale(peak, 95, 140, 75, 100) + // 没迈步只提示、不压分:手臂到位才是评分依据(提示冷却较长,不会每次都念) + if (!trace.flags.has('stepped')) return { score, tip: TIPS.jack.step, joints: [KP.L_ANKLE, KP.R_ANKLE] } return { score } }, } @@ -252,9 +287,9 @@ function raiseRule(): Rule { } return a }, - enter: 68, + enter: 58, exit: 30, - attempt: 42, + attempt: 38, attemptTip: TIPS.raise.lowAttempt, amount: (v) => clamp((v - 10) / 78, 0, 1), attemptJoints: [KP.L_WRIST, KP.R_WRIST], @@ -271,7 +306,7 @@ function raiseRule(): Rule { if (trace.flags.has('shrug')) return { score: 70, tip: TIPS.raise.shrug, joints: [KP.L_SHOULDER, KP.R_SHOULDER] } if (peak > 112) return { score: 75, tip: TIPS.raise.high, joints: [KP.L_WRIST, KP.R_WRIST] } if (trace.flags.has('asym')) return { score: 78, tip: TIPS.raise.asym, joints: [KP.L_WRIST, KP.R_WRIST] } - const score = peak >= 80 ? 100 : scale(peak, 68, 80, 75, 95) + const score = peak >= 80 ? 100 : scale(peak, 58, 80, 75, 95) return { score } }, } @@ -293,13 +328,16 @@ export function createCounter(move: MoveId): RepCounter { let active: RepTrace | null = null let attemptPeak = 0 let lastRepEnd = 0 + let exitStreak = 0 + let enterStreak = 0 let live: Amount = { amount: 0, side: 'L' } + let metric: number | null = null const goal = rule.amount(rule.enter) const lastTipAt = new Map() const allowTip = (text: string, t: number) => { const last = lastTipAt.get(text) ?? -Infinity - if (t - last < TIP_COOLDOWN_MS) return false + if (t - last < (ADVICE_TIPS.has(text) ? ADVICE_COOLDOWN_MS : TIP_COOLDOWN_MS)) return false lastTipAt.set(text, t) return true } @@ -312,6 +350,9 @@ export function createCounter(move: MoveId): RepCounter { get live() { return live }, + get metric() { + return metric + }, goal, reset() { ctx = { baseline: {} } @@ -319,19 +360,28 @@ export function createCounter(move: MoveId): RepCounter { active = null attemptPeak = 0 lastRepEnd = 0 + exitStreak = 0 + enterStreak = 0 live = { amount: 0, side: 'L' } + metric = null lastTipAt.clear() }, update(pose) { const events: CoachEvent[] = [] - const v = rule.metric(pose, ctx) + const raw = rule.metric(pose, ctx) + // 兜底:指标算出 NaN / Infinity(坐标异常)时按看不清处理,不让幅度条和计数卡住 + const v = raw !== null && Number.isFinite(raw) ? raw : null + metric = v if (v === null) return events live = { amount: rule.amount(v), side: rule.side?.(pose) ?? 'L' } if (!active) { - if (v >= rule.enter) { - active = { peak: v, startT: pose.t, flags: new Set() } + enterStreak = v >= rule.enter ? enterStreak + 1 : 0 + if (enterStreak >= ENTER_FRAMES) { + enterStreak = 0 + active = { peak: Math.max(v, attemptPeak), startT: pose.t, flags: new Set() } attemptPeak = 0 + exitStreak = 0 } else if (v >= rule.attempt) { attemptPeak = Math.max(attemptPeak, v) } else if (attemptPeak > 0 && v <= rule.exit) { @@ -344,7 +394,9 @@ export function createCounter(move: MoveId): RepCounter { if (active) { active.peak = Math.max(active.peak, v) rule.checkForm?.(pose, ctx, active.flags) - if (v <= rule.exit) { + exitStreak = v <= rule.exit ? exitStreak + 1 : 0 + if (exitStreak >= (rule.exitFrames ?? 1)) { + exitStreak = 0 count += 1 const judged = rule.judge(active.peak, active) let { tip, joints } = judged diff --git a/TUICallKit-Vue3/training/pose/keypoints.ts b/TUICallKit-Vue3/training/pose/keypoints.ts index 35455a5b7..9c6a218be 100644 --- a/TUICallKit-Vue3/training/pose/keypoints.ts +++ b/TUICallKit-Vue3/training/pose/keypoints.ts @@ -92,8 +92,13 @@ export function angleAt(a: Point, b: Point, c: Point): number { return (Math.acos(cos) * 180) / Math.PI } +/** 坐标是有效数字(真机上画面外的点坐标是 NaN) */ +export function isFinitePoint(p: Point | undefined): p is Point { + return !!p && Number.isFinite(p.x) && Number.isFinite(p.y) +} + export function isVisible(pose: Pose, idx: number, min = MIN_POINT_SCORE): boolean { - return (pose.scores[idx] ?? 0) >= min + return (pose.scores[idx] ?? 0) >= min && isFinitePoint(pose.points[idx]) } /** 肩宽,作为身体尺度基准(对远近不敏感) */ @@ -145,12 +150,14 @@ export const FRAMING_TIPS: Record = { /** * 指数平滑:抑制关键点抖动。alpha 越大越跟手,越小越稳。 * 置信度低的点保留上一帧位置,避免骨架乱跳。 + * 上一帧坐标无效(NaN)时直接用新坐标重新开始——否则 NaN 会一直传下去,这个点永远是 NaN。 */ export function smoothPose(prev: Pose | null, next: Pose, alpha = 0.55): Pose { if (!prev) return next const points = next.points.map((p, i) => { const q = prev.points[i] - if ((next.scores[i] ?? 0) < MIN_POINT_SCORE) return q + if (!isFinitePoint(q)) return p + if ((next.scores[i] ?? 0) < MIN_POINT_SCORE || !isFinitePoint(p)) return q return { x: q.x + (p.x - q.x) * alpha, y: q.y + (p.y - q.y) * alpha } }) const scores = next.scores.map((s, i) => Math.max(s, (prev.scores[i] ?? 0) * 0.7)) @@ -163,6 +170,7 @@ type RawPoint = { x: number; y: number } | [number, number] * 把 VisionKit 的 anchor 转成归一化 Pose。 * 官方示例里 points 已是 0~1 的 {x,y};文档描述为“图像中的坐标”, * 这里两种都兼容:发现坐标 > 1.5 即按像素处理。 + * 画面外的点真机返回 NaN 坐标(置信度却可能不低):坐标置 0、置信度置 0,当作看不见。 */ export function anchorToPose( anchor: { points?: RawPoint[]; confidence?: number[]; score?: number }, @@ -172,14 +180,15 @@ export function anchorToPose( ): Pose | null { const raw = anchor?.points if (!raw || raw.length < 17) return null - const pts: Point[] = raw.map((p) => (Array.isArray(p) ? { x: p[0], y: p[1] } : { x: p.x, y: p.y })) - const isPixel = pts.some((p) => p.x > 1.5 || p.y > 1.5) - const points = pts.map((p) => (isPixel ? { x: p.x / frameWidth, y: p.y / frameHeight } : p)) + const pts: Point[] = raw.map((p) => (Array.isArray(p) ? { x: p[0], y: p[1] } : { x: p?.x, y: p?.y })) + const isPixel = pts.some((p) => isFinitePoint(p) && (p.x > 1.5 || p.y > 1.5)) + const points = pts.map((p) => (!isFinitePoint(p) ? { x: 0, y: 0 } : isPixel ? { x: p.x / frameWidth, y: p.y / frameHeight } : p)) while (points.length < KEYPOINT_COUNT) points.push({ x: 0, y: 0 }) const fallback = typeof anchor.score === 'number' ? anchor.score : 1 const scores = points.map((_, i) => { + if (i >= raw.length || !isFinitePoint(pts[i])) return 0 const c = anchor.confidence?.[i] - return typeof c === 'number' ? c : i < raw.length ? fallback : 0 + return typeof c === 'number' && Number.isFinite(c) ? c : fallback }) return { points, scores, t, aspect: frameWidth / frameHeight } }