362 lines
16 KiB
JavaScript
362 lines
16 KiB
JavaScript
/**
|
||
* 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.
|
||
var FRONT_YAW_MAX = 15;
|
||
var FRONT_PITCH_MAX = 18;
|
||
var SIDE_YAW_MIN = 30;
|
||
var SIDE_YAW_MAX = 70;
|
||
var SIDE_PITCH_MAX = 20;
|
||
var ROLL_MAX = 15;
|
||
|
||
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、|roll|<=15;角度不合格只提示回正,
|
||
* 不在正脸拍摄完成前播侧脸转头指令。
|
||
* 'left' : 画面中看到左脸 = 被摄者头向右转(语义 yaw ∈ [-70°,-30°]),|pitch|<=20
|
||
* 'right' : 画面中看到右脸 = 被摄者头向左转(语义 yaw ∈ [30°,70°]),|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 (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) 侧脸做 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) Phone feedback: restore the 15° frontal margin, still reject 16°+.
|
||
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° 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 === false && sc3.hint === '请向右转头', 'yaw≈16.2° front 拒绝,hint=请向右转头');
|
||
|
||
if (failures > 0) {
|
||
console.error('\n' + failures + ' 项自测失败');
|
||
process.exit(1);
|
||
}
|
||
console.log('\n全部自测通过');
|
||
}
|