Files
juzi/h5/js/head-pose.js
T
2026-10-09 23:22:28 +08:00

364 lines
16 KiB
JavaScript
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.
/**
* head-pose.js —— 纯姿态估算模块(yaw / pitch / roll)
*
* 输入为 face-api.js 的 68 特征点([{x,y}, ...],图片坐标系、未镜像),
* 输出角度制姿态角。纯数据进、纯数据出:不碰 DOM、不 require face-api,
* 浏览器挂 window.HeadPose,Node 下 module.exports,可直接单测。
*
* ------------------------- 坐标与符号约定 -------------------------
* 68 点索引(dlib/face-api 约定,以「看图视角」描述):
* 36 = 画面左侧眼外角(被摄者右眼外角)
* 45 = 画面右侧眼外角(被摄者左眼外角)
* 30 = 鼻尖,33 = 鼻底,19/24 = 左右眉峰,8 = 下巴尖
* 图片坐标系:x 向右,y 向下。
*
* yaw(左右转头):
* yaw > 0 表示鼻尖偏向画面右侧(45 点一侧),即被摄者向【自己的左侧】转头
* (此时画面中看到被摄者的右脸 → check 的 target 'right');
* yaw < 0 表示被摄者向【自己的右侧】转头(画面中看到左脸 → target 'left')。
* mirrored=true(前置摄像头预览做了水平镜像)时内部翻转 yaw 符号,
* 使输出相对用户所看到的画面方向一致;check() 内部会还原语义方向,
* 因此 check 结果与 mirrored 取值无关。
* ⚠ 方向符号真机需校准:不同机型/镜像策略下若左右相反,
* 只需调整调用方传入的 mirrored 标志或 YAW_COEFF 符号。
*
* pitch(俯仰):
* pitch > 0 表示抬头(鼻底相对眉眼连线中点的纵向下移增大),
* pitch < 0 表示低头。⚠ 经验映射,真机需校准 PITCH_BASELINE / PITCH_COEFF。
*
* roll(平面内歪头):
* 两眼外角连线倾角,roll > 0 表示画面右侧(45 点一侧)偏低。
*
* 所有映射均为基于 68 点几何的轻量经验估算(非 solvePnP 精确解),
* 系数仅保证量级与单调性合理,上线前需用真机样本回归校准。
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.HeadPose = factory();
}
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// ---- 68 点索引 ----
var IDX = {
EYE_LEFT_OUTER: 36, // 画面左侧眼外角
EYE_RIGHT_OUTER: 45, // 画面右侧眼外角
NOSE_TIP: 30,
NOSE_BASE: 33,
BROW_LEFT: 19,
BROW_RIGHT: 24,
CHIN: 8
};
// ---- 经验系数(2026-09-27 用 C3 三视图(+45°侧脸)模拟摄像头校准) ----
// yaw = atan((dLeft-dRight)/(dLeft+dRight)) × YAW_COEFF。
// 校准:±45° 实拍侧脸读数 ±25°(2.2 偏小)→ 3.8 使读数落在 44° 上下;正脸 ~4°,仍 <12° 阈值
var YAW_COEFF = 3.8;
// pitch:鼻底相对「眉眼连线中点」的纵向偏移 / 脸高(眉 19/24 — 下巴 8),
// 减去中性正脸基线后线性映射为角度。
var PITCH_BASELINE = 0.45; // 校准:0.32 时正脸误读 ~29°(被误判低头),实测基线 0.48,留 0.03 余量
var PITCH_COEFF = 180; // 归一化偏移 → 角度 的增益
// ---- check 阈值(角度制) ----
// 正脸口径:Face++ 正脸判定 |yaw|<10°;产品要求(2026-09-28)正脸引导放宽一点点,
// 前端取 |yaw|≤15°、|pitch|≤18°(略超 Face++ 口径,正脸图仍可正常分析,仅 orientation 读数略偏)
var FRONT_YAW_MAX = 15;
var FRONT_PITCH_MAX = 18;
var SIDE_YAW_MIN = 28;
var SIDE_YAW_MAX = 75;
var SIDE_PITCH_MAX = 20;
var HINT_KEEP = '保持不动';
var HINT_TURN_LEFT = '请向左转头'; // 用户向自己的左侧转
var HINT_TURN_RIGHT = '请向右转头'; // 用户向自己的右侧转
var HINT_RAISE = '请略微抬头';
var HINT_LOWER = '请略微低头';
var HINT_FRONT = '请正对屏幕';
var RAD2DEG = 180 / Math.PI;
function dist(a, b) {
var dx = a.x - b.x;
var dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
function midpoint(a, b) {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function clamp(v, lo, hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
function assertPoints(points) {
if (!points || typeof points.length !== 'number' || points.length < 68) {
throw new TypeError('HeadPose.estimate: 需要 68 个特征点数组');
}
var names = Object.keys(IDX);
for (var i = 0; i < names.length; i++) {
var p = points[IDX[names[i]]];
if (!p || typeof p.x !== 'number' || typeof p.y !== 'number' ||
!isFinite(p.x) || !isFinite(p.y)) {
throw new TypeError('HeadPose.estimate: 特征点 ' + IDX[names[i]] + ' 非法');
}
}
}
/**
* estimate(points, mirrored) -> { yaw, pitch, roll }(角度制),非法/退化输入返回 null
* points : face-api 68 特征点 [{x,y}, ...](图片坐标系,未镜像)
* mirrored : 预览画面是否水平镜像(true 时翻转 yaw 符号)
* 返回 null 的情形:特征点重合(dLeft+dRight=0,yaw 退化为 NaN)、
* 脸高过小(pitch 不可信,不再静默置 0)、结果出现 NaN/Infinity。
*/
function estimate(points, mirrored) {
assertPoints(points);
var eyeL = points[IDX.EYE_LEFT_OUTER];
var eyeR = points[IDX.EYE_RIGHT_OUTER];
var noseTip = points[IDX.NOSE_TIP];
var noseBase = points[IDX.NOSE_BASE];
var browMid = midpoint(points[IDX.BROW_LEFT], points[IDX.BROW_RIGHT]);
var chin = points[IDX.CHIN];
// roll:两眼外角连线倾角(y 向下,45 点偏低为正)
var roll = Math.atan2(eyeR.y - eyeL.y, eyeR.x - eyeL.x) * RAD2DEG;
// yaw:鼻尖到左右眼外角距离的不对称度;特征点重合(dLeft+dRight=0)时为 NaN,走非法分支
var dLeft = dist(noseTip, eyeL);
var dRight = dist(noseTip, eyeR);
var dSum = dLeft + dRight;
var yaw = NaN;
if (dSum > 0) yaw = Math.atan((dLeft - dRight) / dSum) * RAD2DEG * YAW_COEFF;
// pitch:鼻底相对眉眼连线中点的纵向偏移,以脸高归一后线性映射;
// 脸高过小时 pitch 不可信,不再静默置 0,走非法分支
var eyeMid = midpoint(eyeL, eyeR);
var browEyeMid = midpoint(browMid, eyeMid);
var faceHeight = chin.y - browMid.y;
var pitch = NaN;
if (faceHeight > 1) {
var ratio = (noseBase.y - browEyeMid.y) / faceHeight;
pitch = (ratio - PITCH_BASELINE) * PITCH_COEFF;
}
if (!isFinite(yaw) || !isFinite(pitch) || !isFinite(roll)) return null; // 退化输入 → 非法三角
yaw = clamp(yaw, -90, 90);
pitch = clamp(pitch, -90, 90);
if (mirrored) { yaw = -yaw; }
return { yaw: yaw, pitch: pitch, roll: roll };
}
/**
* check(points, target, mirrored) -> { ok, hint }
* target: 'front' | 'left' | 'right'
* 'front' : |yaw|<=15 且 |pitch|<=18;不 ok 时按超出更多的一项给方向提示
* (UI 反馈 2026-10:动态提示「请向左转头」「请略微抬头」等,取代此前只提示回正)
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-75°,-28°]),|pitch|<=20
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [28°,75°]),|pitch|<=20
* ok=true 时 hint='保持不动';输入非法(含 estimate 返回 null 的退化特征点)时 ok=false、hint='请正对屏幕'。
*/
function check(points, target, mirrored) {
var e;
try {
e = estimate(points, mirrored);
} catch (err) {
return { ok: false, hint: HINT_FRONT, yaw: null };
}
if (!e) return { ok: false, hint: HINT_FRONT, yaw: null }; // 退化特征点(重合/脸高过小 → NaN/Infinity)
// 语义 yaw:>0 = 被摄者向自己的左侧转头。estimate(mirrored) 内部已按镜像翻转过符号,
// 这里直接用 e.yaw(此前再翻一次是双重镜像 bug:侧脸引导方向完全相反)
var sYaw = e.yaw;
var pitch = e.pitch;
if (target === 'front') {
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP, yaw: sYaw };
}
// 按超出阈值比例更大的一项引导回正:头偏向自己左侧(yaw>0)→请向右转头;抬头过多(pitch>0)→请略微低头
var yawOver = Math.abs(sYaw) / FRONT_YAW_MAX, pitchOver = Math.abs(pitch) / FRONT_PITCH_MAX;
if (yawOver >= pitchOver) {
return { ok: false, hint: sYaw > 0 ? HINT_TURN_RIGHT : HINT_TURN_LEFT, yaw: sYaw };
}
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE, yaw: sYaw };
}
if (target === 'left' || target === 'right') {
// 'left' 需要语义 yaw 为负(头向右转),'right' 需要为正
var want = target === 'left' ? -1 : 1;
var sy = sYaw * want; // 统一折算到正区间判断
if (sy >= SIDE_YAW_MIN && sy <= SIDE_YAW_MAX && Math.abs(pitch) <= SIDE_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP, yaw: sYaw };
}
if (sy < SIDE_YAW_MIN) {
// 转得不够(含转错方向):提示朝目标方向转
return { ok: false, hint: target === 'left' ? HINT_TURN_RIGHT : HINT_TURN_LEFT, yaw: sYaw };
}
if (sy > SIDE_YAW_MAX) {
// 转过了:提示往回
return { ok: false, hint: target === 'left' ? HINT_TURN_LEFT : HINT_TURN_RIGHT, yaw: sYaw };
}
return { ok: false, hint: pitch > 0 ? HINT_LOWER : HINT_RAISE, yaw: sYaw };
}
return { ok: false, hint: HINT_FRONT, yaw: null };
}
return {
estimate: estimate,
check: check,
// 导出阈值与系数便于调用方/真机校准阶段调整
config: {
YAW_COEFF: YAW_COEFF,
PITCH_BASELINE: PITCH_BASELINE,
PITCH_COEFF: PITCH_COEFF,
FRONT_YAW_MAX: FRONT_YAW_MAX,
FRONT_PITCH_MAX: FRONT_PITCH_MAX,
SIDE_YAW_MIN: SIDE_YAW_MIN,
SIDE_YAW_MAX: SIDE_YAW_MAX,
SIDE_PITCH_MAX: SIDE_PITCH_MAX
}
};
}));
// -------------------------- Node 自测 --------------------------
if (typeof module !== 'undefined' && typeof require !== 'undefined' && require.main === module) {
var HeadPose = module.exports;
var failures = 0;
function ok(cond, msg) {
if (cond) {
console.log('PASS ' + msg);
} else {
failures++;
console.error('FAIL ' + msg);
}
}
// 合成对称正脸:眼距 100,脸高 170,pitch 偏移恰好落在基线上
function makeFrontal() {
var pts = [];
for (var i = 0; i < 68; i++) { pts.push({ x: 200, y: 150 }); }
pts[36] = { x: 150, y: 100 }; // 左眼外角(画面左)
pts[45] = { x: 250, y: 100 }; // 右眼外角(画面右)
pts[19] = { x: 170, y: 70 };
pts[24] = { x: 230, y: 70 };
pts[8] = { x: 200, y: 240 }; // 下巴 → 脸高 240-70=170
pts[30] = { x: 200, y: 140 }; // 鼻尖居中
pts[33] = { x: 200, y: 161.5 }; // (161.5-85)/170 = 0.45 = PITCH_BASELINE
return pts;
}
// 把鼻点横向偏移 17.7px,模拟 ~45° 侧脸(向画面右侧偏 = 被摄者向左转;偏移量按 YAW_COEFF=3.8 数值标定)
function makeSide() {
var pts = makeFrontal();
pts[30] = { x: 217.7, y: 140 };
pts[33] = { x: 217.7, y: 161.5 };
return pts;
}
// 1) 对称正脸 → yaw/pitch/roll ≈ 0
var front = HeadPose.estimate(makeFrontal(), false);
ok(Math.abs(front.yaw) < 0.5, '正脸 yaw≈0 (got ' + front.yaw.toFixed(2) + ')');
ok(Math.abs(front.pitch) < 1, '正脸 pitch≈0 (got ' + front.pitch.toFixed(2) + ')');
ok(Math.abs(front.roll) < 0.5, '正脸 roll≈0 (got ' + front.roll.toFixed(2) + ')');
// 2) 正脸 front check 通过
var fc = HeadPose.check(makeFrontal(), 'front', false);
ok(fc.ok === true && fc.hint === '保持不动', '正脸 front ok,hint=保持不动');
// 3) 45° 侧脸:读数落在合理区间
var side = HeadPose.estimate(makeSide(), false);
ok(side.yaw > 40 && side.yaw < 50, '侧脸 yaw≈45° (got ' + side.yaw.toFixed(2) + ')');
// 4) 方向语义:鼻尖偏画面右 = 被摄者向左转 = target 'right' 通过、'left' 拒绝
var rc = HeadPose.check(makeSide(), 'right', false);
var lc = HeadPose.check(makeSide(), 'left', false);
ok(rc.ok === true, '侧脸(被摄者左转)target=right ok');
ok(lc.ok === false && lc.hint === '请向右转头', '同侧脸 target=left 拒绝并引导向右');
// 5) mirrored=true 翻转 yaw 符号,check 语义随之翻转(同一点集在镜像预览下对应相反的物理转头)
var sideM = HeadPose.estimate(makeSide(), true);
ok(Math.abs(sideM.yaw + side.yaw) < 0.001, 'mirrored=true 时 yaw 符号翻转');
ok(HeadPose.check(makeSide(), 'right', true).ok === false, 'mirrored 语义翻转:check(right) 拒绝');
ok(HeadPose.check(makeSide(), 'left', true).ok === true, 'mirrored 语义翻转:check(left) 通过');
// 6) 反方向侧脸:鼻尖偏画面左 → target 'left' 通过
var back = makeSide();
back[30] = { x: 182.3, y: 140 };
back[33] = { x: 182.3, y: 161.5 };
var bc = HeadPose.check(back, 'left', false);
ok(bc.ok === true, '反向侧脸 target=left ok');
ok(HeadPose.check(back, 'right', false).ok === false, '反向侧脸 target=right 拒绝');
// 7) 侧脸做 front check → 拒绝并引导转回(被摄者左转 → 请向右转头;反向 → 请向左转头)
var sc = HeadPose.check(makeSide(), 'front', false);
ok(sc.ok === false && sc.hint === '请向右转头', '左转侧脸 front 拒绝,hint=请向右转头');
var scb = makeSide();
scb[30] = { x: 182.3, y: 140 };
scb[33] = { x: 182.3, y: 161.5 };
ok(HeadPose.check(scb, 'front', false).hint === '请向左转头', '右转侧脸 front 拒绝,hint=请向左转头');
// 8) roll:右眼(45 点)下移 10px → roll≈5.7°
var tilted = makeFrontal();
tilted[45] = { x: 250, y: 110 };
ok(Math.abs(HeadPose.estimate(tilted, false).roll - 5.71) < 0.2, 'roll≈5.71°');
// 9) 低头:鼻底上移 → pitch<0,front 拒绝并提示略微抬头
var down = makeFrontal();
down[33] = { x: 200, y: 100 };
var de = HeadPose.estimate(down, false);
var dc = HeadPose.check(down, 'front', false);
ok(de.pitch < -30, '低头 pitch<0 (got ' + de.pitch.toFixed(2) + ')');
ok(dc.ok === false && dc.hint === '请略微抬头', '低头 front 拒绝,hint=请略微抬头');
var up = makeFrontal();
up[33] = { x: 200, y: 200 };
ok(HeadPose.check(up, 'front', false).hint === '请略微低头', '抬头过多 front 拒绝,hint=请略微低头');
// 10) 非法输入:check 不抛异常,返回兜底引导
var bad = HeadPose.check(null, 'front', false);
ok(bad.ok === false && bad.hint === '请正对屏幕', '非法输入兜底 hint=请正对屏幕');
// 11) 退化特征点:全部重合 → dLeft+dRight=0 → yaw=NaN → estimate 返回 null,check 兜底「请正对屏幕」(不再误导「请向左转头」)
var flat = [];
for (var fi = 0; fi < 68; fi++) { flat.push({ x: 100, y: 100 }); }
ok(HeadPose.estimate(flat, false) === null, '特征点重合 → estimate 返回 null');
var fc2 = HeadPose.check(flat, 'front', false);
ok(fc2.ok === false && fc2.hint === '请正对屏幕', '退化输入 check 兜底 hint=请正对屏幕');
// 12) 脸高过小 → pitch 不可信,不再静默置 0,走非法分支
var tiny = makeFrontal();
tiny[8] = { x: 200, y: 70.5 }; // faceHeight = 0.5 ≤ 1
ok(HeadPose.estimate(tiny, false) === null, 'faceHeight 过小 → estimate 返回 null');
ok(HeadPose.check(tiny, 'front', false).hint === '请正对屏幕', 'faceHeight 过小 check 兜底');
// 13) 正脸阈值放宽到 |yaw|≤15°(产品要求 2026-09-28):yaw≈14.9° 应通过,yaw≈16.2° 应拒绝并引导转回
ok(HeadPose.config.FRONT_YAW_MAX === 15, 'config.FRONT_YAW_MAX = 15');
var slight = makeFrontal();
slight[30] = { x: 205.6, y: 140 }; // 偏移 5.6 → yaw≈14.9°(YAW_COEFF=3.8 标定)
slight[33] = { x: 205.6, y: 161.5 };
var se = HeadPose.estimate(slight, false);
ok(se.yaw > 13.5 && se.yaw <= 15, '轻微偏头 yaw∈(13.5,15] (got ' + se.yaw.toFixed(2) + ')');
var sc2 = HeadPose.check(slight, 'front', false);
ok(sc2.ok === true, 'yaw≈14.9°(≤15°)front 通过(旧阈值 12 会拒绝)');
var slight2 = makeFrontal();
slight2[30] = { x: 206.2, y: 140 }; // 偏移 6.2 → yaw≈16.2°
slight2[33] = { x: 206.2, y: 161.5 };
var se2 = HeadPose.estimate(slight2, false);
ok(se2.yaw > 15 && se2.yaw < 17.5, '偏头 yaw∈(15,17.5) (got ' + se2.yaw.toFixed(2) + ')');
var sc3 = HeadPose.check(slight2, 'front', false);
ok(sc3.ok === false && sc3.hint === '请向右转头', 'yaw≈16.2° front 拒绝,hint=请向右转头');
if (failures > 0) {
console.error('\n' + failures + ' 项自测失败');
process.exit(1);
}
console.log('\n全部自测通过');
}