Files
zyt/TUICallKit-Vue3/training/pose/keypoints.ts
T
long 4932ed7651 refactor: 移除 AI 跟练中没有作用的代码
- 删除从未显示的 idle 手势帧(小程序包内 png 和视频用高清帧),训练分包减少 17KB
- 删除骨架连线 BONES、镜像函数 mirrorPose(骨架已去掉)和未使用的 Bgm 类型
- 只给单测用的 sampleMotion 移到测试目录
- 删除训练菜单里未使用的 .menu-icon-text 样式
- 补全 history 单测数据缺少的 adviceKind 字段(严格类型检查报错)
2026-10-31 10:33:25 +08:00

186 lines
6.0 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
}
export function isVisible(pose: Pose, idx: number, min = MIN_POINT_SCORE): boolean {
return (pose.scores[idx] ?? 0) >= min
}
/** 肩宽,作为身体尺度基准(对远近不敏感) */
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 越大越跟手,越小越稳。
* 置信度低的点保留上一帧位置,避免骨架乱跳。
*/
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
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 即按像素处理。
*/
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) => p.x > 1.5 || p.y > 1.5)
const points = pts.map((p) => (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) => {
const c = anchor.confidence?.[i]
return typeof c === 'number' ? c : i < raw.length ? fallback : 0
})
return { points, scores, t, aspect: frameWidth / frameHeight }
}