fix(training): 修复 AI 跟练真机不计数和误计数,放宽动作要求

真机日志定位的问题:
- 画面外的关键点坐标是 NaN 或字符串,平滑后永久污染膝、踝坐标,深蹲指标恒为 0、站位却被误判通过。
  入口处把非数字坐标当作看不见,平滑时上一帧无效就用新坐标重新开始
- 识别引擎单帧跳动越过计数线也会计一次:连续 2 帧达到计数线才算开始;深蹲连续 2 帧回到站立才算完成
- 离太近时腿部点置信度只有 0.1~0.2、位置靠猜:计数的可见阈值保持 0.3,不用膝盖替补脚踝

按真机跟练的实际幅度放宽:深蹲 0.18→0.13、高抬腿 0.22→0.16、开合跳 120°→95°、侧平举 68°→58°,
左右只看清一侧也能算;开合跳 140° 满分,没迈步只提示不压分。
This commit is contained in:
2026-10-31 11:56:31 +08:00
parent 84be21b1bf
commit a8d00597f4
4 changed files with 310 additions and 44 deletions
@@ -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}`)
})
@@ -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)
})
+90 -38
View File
@@ -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<string>) => 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<string> = 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<string, number>()
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
+15 -6
View File
@@ -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<FramingIssue, string> = {
/**
* 指数平滑:抑制关键点抖动。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 }
}