真机日志定位的问题: - 画面外的关键点坐标是 NaN 或字符串,平滑后永久污染膝、踝坐标,深蹲指标恒为 0、站位却被误判通过。 入口处把非数字坐标当作看不见,平滑时上一帧无效就用新坐标重新开始 - 识别引擎单帧跳动越过计数线也会计一次:连续 2 帧达到计数线才算开始;深蹲连续 2 帧回到站立才算完成 - 离太近时腿部点置信度只有 0.1~0.2、位置靠猜:计数的可见阈值保持 0.3,不用膝盖替补脚踝 按真机跟练的实际幅度放宽:深蹲 0.18→0.13、高抬腿 0.22→0.16、开合跳 120°→95°、侧平举 68°→58°, 左右只看清一侧也能算;开合跳 140° 满分,没迈步只提示不压分。
195 lines
6.7 KiB
TypeScript
195 lines
6.7 KiB
TypeScript
/**
|
||
* 人体关键点定义与几何工具
|
||
*
|
||
* 点位遵循微信 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 }
|
||
}
|