/** * 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全部自测通过'); }