Files
zyt/TUICallKit-Vue3/training/pose/keypoints.ts
T
long a8d00597f4 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° 满分,没迈步只提示不压分。
2026-10-31 11:56:31 +08:00

195 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 人体关键点定义与几何工具
*
* 点位遵循微信 VisionKit body 检测的 23 点定义(基础库 >= 2.28.0):
* 0 鼻子 1/2 左/右眼 3/4 左/右耳
* 5/6 左/右肩 7/8 左/右肘 9/10 左/右腕
* 11/12 左/右髋 13/14 左/右膝 15/16 左/右踝
* 17/18 左/右手尖 19/20 左/右脚外侧 21/22 左/右脚内侧
* “左/右”指被拍摄者自身的左右。
*
* 本文件不依赖 uni / wx,可在 Node 单测与 Remotion 中直接复用。
*/
export type Point = { x: number; y: number }
/** 一帧姿态:23 个点,坐标归一化到 [0,1],score 为该点置信度(0~1) */
export type Pose = {
points: Point[]
scores: number[]
/** 采集时间戳(ms) */
t: number
/** 画面宽高比(宽/高),用于把 x 换算到与 y 同一尺度;缺省为 1 */
aspect?: number
}
/** 取某个点,并把 x 换算到与 y 相同的尺度(角度、距离计算必须用它) */
export function pt(pose: Pose, idx: number): Point {
const p = pose.points[idx]
return { x: p.x * (pose.aspect ?? 1), y: p.y }
}
export const KP = {
NOSE: 0,
L_EYE: 1,
R_EYE: 2,
L_EAR: 3,
R_EAR: 4,
L_SHOULDER: 5,
R_SHOULDER: 6,
L_ELBOW: 7,
R_ELBOW: 8,
L_WRIST: 9,
R_WRIST: 10,
L_HIP: 11,
R_HIP: 12,
L_KNEE: 13,
R_KNEE: 14,
L_ANKLE: 15,
R_ANKLE: 16,
L_HAND: 17,
R_HAND: 18,
L_FOOT_OUT: 19,
R_FOOT_OUT: 20,
L_FOOT_IN: 21,
R_FOOT_IN: 22,
} as const
export const KEYPOINT_COUNT = 23
/** 判定“全身入镜”需要看得到的点 */
export const FULL_BODY_POINTS = [
KP.NOSE,
KP.L_SHOULDER,
KP.R_SHOULDER,
KP.L_HIP,
KP.R_HIP,
KP.L_KNEE,
KP.R_KNEE,
KP.L_ANKLE,
KP.R_ANKLE,
]
export const MIN_POINT_SCORE = 0.3
export function dist(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
export function mid(a: Point, b: Point): Point {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
}
/** 以 b 为顶点的夹角 ∠abc,单位度,范围 [0,180] */
export function angleAt(a: Point, b: Point, c: Point): number {
const v1x = a.x - b.x
const v1y = a.y - b.y
const v2x = c.x - b.x
const v2y = c.y - b.y
const n = Math.hypot(v1x, v1y) * Math.hypot(v2x, v2y)
if (n === 0) return 180
const cos = Math.min(1, Math.max(-1, (v1x * v2x + v1y * v2y) / n))
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 && isFinitePoint(pose.points[idx])
}
/** 肩宽,作为身体尺度基准(对远近不敏感) */
export function shoulderWidth(pose: Pose): number {
return dist(pt(pose, KP.L_SHOULDER), pt(pose, KP.R_SHOULDER))
}
/** 躯干长度:肩中点到髋中点 */
export function torsoLength(pose: Pose): number {
const s = mid(pt(pose, KP.L_SHOULDER), pt(pose, KP.R_SHOULDER))
const h = mid(pt(pose, KP.L_HIP), pt(pose, KP.R_HIP))
return dist(s, h)
}
export type FramingIssue = 'none' | 'no_body' | 'too_close' | 'too_far' | 'off_center' | 'partial'
/**
* 站位检查:是否全身入镜、远近是否合适、是否居中。
* 返回第一个需要纠正的问题。
*/
export function checkFraming(pose: Pose | null): FramingIssue {
if (!pose) return 'no_body'
const missing = FULL_BODY_POINTS.filter((i) => !isVisible(pose, i))
if (missing.length >= FULL_BODY_POINTS.length - 2) return 'no_body'
const top = pose.points[KP.NOSE].y
const bottom = Math.max(pose.points[KP.L_ANKLE].y, pose.points[KP.R_ANKLE].y)
const height = bottom - top
const anklesMissing = !isVisible(pose, KP.L_ANKLE) || !isVisible(pose, KP.R_ANKLE)
if (anklesMissing || bottom > 0.98 || top < 0.02) return 'too_close'
if (missing.length > 0) return 'partial'
if (height < 0.35) return 'too_far'
const cx = (pose.points[KP.L_HIP].x + pose.points[KP.R_HIP].x) / 2
if (cx < 0.25 || cx > 0.75) return 'off_center'
return 'none'
}
export const FRAMING_TIPS: Record<FramingIssue, string> = {
none: '站位很好,保持住',
no_body: '请站到镜头前',
too_close: '请往后退,让头和脚都进入画面',
too_far: '请再往前走一点',
off_center: '请站到画面中间',
partial: '请让全身都进入画面',
}
/**
* 指数平滑:抑制关键点抖动。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 (!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))
return { points, scores, t: next.t, aspect: next.aspect }
}
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 },
frameWidth: number,
frameHeight: number,
t: number,
): 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) => 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' && Number.isFinite(c) ? c : fallback
})
return { points, scores, t, aspect: frameWidth / frameHeight }
}