Files
juzi/h5/js/head-pose.js
T

363 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 阈值(角度制) ----
// Real-phone feedback: relaxed alignment, without accepting frontal photos
// as side views. These empirical angles are not upstream quality scores.
// 2026-10-15: expand accepted angles by 20%; lower side minimum by 20%.
var FRONT_YAW_MAX = 18;
var FRONT_PITCH_MAX = 26;
var SIDE_YAW_MIN = 24;
var SIDE_YAW_MAX = 84;
var SIDE_PITCH_MAX = 29;
var ROLL_MAX = 22;
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|<=18 且 |pitch|<=26、|roll|<=22;角度不合格只提示回正,
* 不在正脸拍摄完成前播侧脸转头指令。
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-84°,-24°]),|pitch|<=29
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [24°,84°]),|pitch|<=29
* 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 (Math.abs(e.roll) > ROLL_MAX) return { ok: false, hint: '请勿歪头', yaw: sYaw };
if (target === 'front') {
if (Math.abs(sYaw) <= FRONT_YAW_MAX && Math.abs(pitch) <= FRONT_PITCH_MAX) {
return { ok: true, hint: HINT_KEEP, yaw: sYaw };
}
return { ok: false, hint: HINT_FRONT, 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,
ROLL_MAX: ROLL_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) 正面步骤只提示回正,不能提前引导拍下一张侧脸。
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) Phone feedback: expand the frontal margin to 18 degrees.
ok(HeadPose.config.FRONT_YAW_MAX === 18, 'config.FRONT_YAW_MAX = 18');
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° front 通过(真机反馈放宽)');
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 === true, 'yaw≈16.2° front 通过(放宽20%)');
if (failures > 0) {
console.error('\n' + failures + ' 项自测失败');
process.exit(1);
}
console.log('\n全部自测通过');
}